oxigdal-proj 0.1.5

Pure Rust coordinate transformation and projection support for OxiGDAL - EPSG database and CRS operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! Vincenty geodetic distance and azimuth calculations on an arbitrary ellipsoid.
//!
//! Implements Vincenty's (1975) inverse and direct formulae for geodesic distance/azimuth.
//! Reference: Vincenty, T. (1975). "Direct and inverse solutions of geodesics on the ellipsoid
//! with application of nested equations". Survey Review 23(176):88–93.
//!
//! The inverse formula computes the geodesic distance and forward/reverse azimuths between two
//! geographic coordinates. The direct formula computes the destination point and reverse azimuth
//! given a starting point, forward azimuth, and distance.
//!
//! # Accuracy
//!
//! The Vincenty formulae are accurate to within 0.5 mm for all points on the ellipsoid except
//! for nearly antipodal points where convergence may fail.
//!
//! # Examples
//!
//! ```
//! use oxigdal_proj::geodesic::wgs84_inverse;
//!
//! let result = wgs84_inverse(51.5074, -0.1278, 48.8566, 2.3522).unwrap();
//! println!("London-Paris: {:.0} m", result.distance_m);
//! ```

#[cfg(not(feature = "std"))]
use alloc::string::{String, ToString};

use core::fmt;

// ─────────────────────────────────────────────────────────────────────────────
// WGS84 constants
// ─────────────────────────────────────────────────────────────────────────────

/// WGS84 semi-major axis in metres.
pub const WGS84_A: f64 = 6_378_137.0;

/// WGS84 semi-minor axis in metres.
pub const WGS84_B: f64 = 6_356_752.314_245_179;

/// WGS84 mean radius used for haversine calculations (metres).
/// Defined as (2a + b) / 3, the authalic mean radius of WGS84.
pub const WGS84_MEAN_RADIUS: f64 = 6_371_008.8;

/// Default maximum iterations for Vincenty convergence.
pub const DEFAULT_MAX_ITER: u32 = 100;

/// Default convergence tolerance for Vincenty (radians).
pub const DEFAULT_TOL: f64 = 1e-12;

// ─────────────────────────────────────────────────────────────────────────────
// Ellipsoid parameters
// ─────────────────────────────────────────────────────────────────────────────

/// Reference ellipsoid parameters used in Vincenty geodesic computations.
///
/// Groups the semi-major axis, semi-minor axis, and convergence controls so that
/// the Vincenty functions stay within the clippy `too_many_arguments` limit.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GeodesicParams {
    /// Semi-major axis in metres (equatorial radius).
    pub a: f64,
    /// Semi-minor axis in metres (polar radius).
    pub b: f64,
    /// Maximum number of iterations for convergence.
    pub max_iter: u32,
    /// Convergence tolerance in radians.
    pub tol: f64,
}

impl GeodesicParams {
    /// Construct custom ellipsoid parameters with default convergence settings.
    pub fn new(a: f64, b: f64) -> Self {
        Self {
            a,
            b,
            max_iter: DEFAULT_MAX_ITER,
            tol: DEFAULT_TOL,
        }
    }

    /// Construct with explicit convergence controls.
    pub fn with_convergence(a: f64, b: f64, max_iter: u32, tol: f64) -> Self {
        Self {
            a,
            b,
            max_iter,
            tol,
        }
    }

    /// WGS84 ellipsoid with default convergence settings.
    pub fn wgs84() -> Self {
        Self::new(WGS84_A, WGS84_B)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Public result types
// ─────────────────────────────────────────────────────────────────────────────

/// Result of a Vincenty inverse (geodesic distance + azimuths) computation.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VincentyResult {
    /// Geodesic distance in metres.
    pub distance_m: f64,
    /// Forward azimuth (bearing from point 1 to point 2), degrees [0, 360).
    pub azimuth_fwd_deg: f64,
    /// Reverse azimuth (bearing from point 2 towards point 1), degrees [0, 360).
    pub azimuth_rev_deg: f64,
    /// Number of iterations needed to reach convergence.
    pub iterations: u32,
}

impl fmt::Display for VincentyResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "distance={:.3} m, fwd_az={:.6}°, rev_az={:.6}°, iters={}",
            self.distance_m, self.azimuth_fwd_deg, self.azimuth_rev_deg, self.iterations
        )
    }
}

/// Result of a Vincenty direct computation (destination + reverse azimuth).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VincentyDirectResult {
    /// Latitude of the destination point in degrees.
    pub lat2_deg: f64,
    /// Longitude of the destination point in degrees.
    pub lon2_deg: f64,
    /// Reverse azimuth at the destination (bearing back toward origin), degrees [0, 360).
    pub azimuth_rev_deg: f64,
}

impl fmt::Display for VincentyDirectResult {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "lat={:.8}°, lon={:.8}°, rev_az={:.6}°",
            self.lat2_deg, self.lon2_deg, self.azimuth_rev_deg
        )
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Error type
// ─────────────────────────────────────────────────────────────────────────────

/// Errors that can occur during geodesic computations.
#[derive(Debug, Clone, PartialEq)]
pub enum GeodesicError {
    /// Vincenty inverse failed to converge — points are (nearly) antipodal.
    AntipodalPoints,
    /// Input values are out of valid range or otherwise invalid.
    InvalidInput(String),
}

impl fmt::Display for GeodesicError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GeodesicError::AntipodalPoints => write!(
                f,
                "Vincenty inverse failed to converge: points are nearly antipodal"
            ),
            GeodesicError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
        }
    }
}

#[cfg(feature = "std")]
impl std::error::Error for GeodesicError {}

// ─────────────────────────────────────────────────────────────────────────────
// Internal helpers
// ─────────────────────────────────────────────────────────────────────────────

/// Normalise an azimuth in **radians** to degrees in [0, 360).
#[inline]
fn azi_to_deg(azi_rad: f64) -> f64 {
    let deg = azi_rad.to_degrees();
    (deg % 360.0 + 360.0) % 360.0
}

/// Validate geographic coordinates (degrees).
fn validate_lat_lon(lat: f64, lon: f64, label: &str) -> Result<(), GeodesicError> {
    if !lat.is_finite() || !lon.is_finite() {
        return Err(GeodesicError::InvalidInput(
            #[cfg(feature = "std")]
            format!("{label}: lat/lon must be finite numbers (got lat={lat}, lon={lon})"),
            #[cfg(not(feature = "std"))]
            "lat/lon must be finite numbers".to_string(),
        ));
    }
    if !(-90.0..=90.0).contains(&lat) {
        return Err(GeodesicError::InvalidInput(
            #[cfg(feature = "std")]
            format!("{label}: latitude {lat} is outside [-90, 90]"),
            #[cfg(not(feature = "std"))]
            "latitude out of range [-90, 90]".to_string(),
        ));
    }
    if !(-180.0..=180.0).contains(&lon) {
        return Err(GeodesicError::InvalidInput(
            #[cfg(feature = "std")]
            format!("{label}: longitude {lon} is outside [-180, 180]"),
            #[cfg(not(feature = "std"))]
            "longitude out of range [-180, 180]".to_string(),
        ));
    }
    Ok(())
}

// ─────────────────────────────────────────────────────────────────────────────
// Vincenty inverse
// ─────────────────────────────────────────────────────────────────────────────

/// Vincenty inverse formula: geodesic distance and azimuths between two (lat, lon) points.
///
/// # Parameters
/// - `lat1_deg`, `lon1_deg` — first point in decimal degrees
/// - `lat2_deg`, `lon2_deg` — second point in decimal degrees
/// - `params` — ellipsoid parameters and convergence controls (see [`GeodesicParams`])
///
/// # Errors
/// Returns [`GeodesicError::AntipodalPoints`] if convergence fails (nearly antipodal case).
/// Returns [`GeodesicError::InvalidInput`] if coordinates are out of valid range.
pub fn vincenty_inverse(
    lat1_deg: f64,
    lon1_deg: f64,
    lat2_deg: f64,
    lon2_deg: f64,
    params: GeodesicParams,
) -> Result<VincentyResult, GeodesicError> {
    validate_lat_lon(lat1_deg, lon1_deg, "point 1")?;
    validate_lat_lon(lat2_deg, lon2_deg, "point 2")?;
    let GeodesicParams {
        a,
        b,
        max_iter,
        tol,
    } = params;

    // Coincident-point short-circuit
    if (lat1_deg - lat2_deg).abs() < f64::EPSILON && (lon1_deg - lon2_deg).abs() < f64::EPSILON {
        return Ok(VincentyResult {
            distance_m: 0.0,
            azimuth_fwd_deg: 0.0,
            azimuth_rev_deg: 0.0,
            iterations: 0,
        });
    }

    let f = (a - b) / a; // flattening

    // Convert to radians
    let phi1 = lat1_deg.to_radians();
    let phi2 = lat2_deg.to_radians();
    let l = (lon2_deg - lon1_deg).to_radians();

    // Reduced latitudes (latitude on the auxiliary sphere)
    let one_minus_f = 1.0 - f;
    let u1 = (one_minus_f * phi1.tan()).atan();
    let u2 = (one_minus_f * phi2.tan()).atan();

    let sin_u1 = u1.sin();
    let cos_u1 = u1.cos();
    let sin_u2 = u2.sin();
    let cos_u2 = u2.cos();

    /// State captured from each Vincenty inverse iteration.
    struct IterState {
        sin_sigma: f64,
        cos_sigma: f64,
        sigma: f64,
        cos2_alpha: f64,
        cos2_sigma_m: f64,
        lambda: f64,
    }

    /// Run one Vincenty inverse iteration given the current λ, returning updated state.
    #[inline]
    fn vincenty_iter_step(
        lambda: f64,
        l: f64,
        f: f64,
        sin_u1: f64,
        cos_u1: f64,
        sin_u2: f64,
        cos_u2: f64,
    ) -> IterState {
        let sin_lambda = lambda.sin();
        let cos_lambda = lambda.cos();

        let term_a = cos_u2 * sin_lambda;
        let term_b = cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda;
        let sin_sigma = (term_a * term_a + term_b * term_b).sqrt();
        let cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
        let sigma = sin_sigma.atan2(cos_sigma);

        let sin_alpha = if sin_sigma.abs() < f64::EPSILON {
            0.0
        } else {
            cos_u1 * cos_u2 * sin_lambda / sin_sigma
        };
        let cos2_alpha = 1.0 - sin_alpha * sin_alpha;

        let cos2_sigma_m = if cos2_alpha.abs() < f64::EPSILON {
            0.0
        } else {
            cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha
        };

        let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
        let new_lambda = l
            + (1.0 - c)
                * f
                * sin_alpha
                * (sigma
                    + c * sin_sigma
                        * (cos2_sigma_m
                            + c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)));

        IterState {
            sin_sigma,
            cos_sigma,
            sigma,
            cos2_alpha,
            cos2_sigma_m,
            lambda: new_lambda,
        }
    }

    // Iterative solution — λ starts at L
    let mut lambda = l;
    let mut iter: u32 = 0;
    let state = loop {
        iter += 1;
        let state = vincenty_iter_step(lambda, l, f, sin_u1, cos_u1, sin_u2, cos_u2);
        let delta = (state.lambda - lambda).abs();
        lambda = state.lambda;

        if delta < tol {
            break state;
        }

        if iter >= max_iter {
            return Err(GeodesicError::AntipodalPoints);
        }
    };

    let sin_sigma = state.sin_sigma;
    let cos_sigma = state.cos_sigma;
    let sigma = state.sigma;
    let cos2_alpha = state.cos2_alpha;
    let cos2_sigma_m = state.cos2_sigma_m;
    lambda = state.lambda;

    // Post-convergence: compute distance
    let u2_sq = cos2_alpha * (a * a - b * b) / (b * b);

    let big_a =
        1.0 + u2_sq / 16384.0 * (4096.0 + u2_sq * (-768.0 + u2_sq * (320.0 - 175.0 * u2_sq)));
    let big_b = u2_sq / 1024.0 * (256.0 + u2_sq * (-128.0 + u2_sq * (74.0 - 47.0 * u2_sq)));

    let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
    let sin_sigma_sq = sin_sigma * sin_sigma;

    let delta_sigma = big_b
        * sin_sigma
        * (cos2_sigma_m
            + big_b / 4.0
                * (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)
                    - big_b / 6.0
                        * cos2_sigma_m
                        * (-3.0 + 4.0 * sin_sigma_sq)
                        * (-3.0 + 4.0 * cos2_sigma_m_sq)));

    let distance = b * big_a * (sigma - delta_sigma);

    // Azimuths
    // alpha1 = forward azimuth at P1 (bearing from P1 toward P2)
    // alpha2 = Vincenty's geodesic azimuth at P2 in the *forward* direction of travel.
    //          The "reverse azimuth" (bearing from P2 back to P1) is alpha2 + 180°.
    let sin_lambda = lambda.sin();
    let cos_lambda = lambda.cos();

    let alpha1 = (cos_u2 * sin_lambda).atan2(cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda);
    let alpha2_fwd = (cos_u1 * sin_lambda).atan2(-sin_u1 * cos_u2 + cos_u1 * sin_u2 * cos_lambda);

    // Convert alpha2 to the back-bearing (from P2 toward P1)
    let alpha2_back_deg = (azi_to_deg(alpha2_fwd) + 180.0) % 360.0;

    Ok(VincentyResult {
        distance_m: distance,
        azimuth_fwd_deg: azi_to_deg(alpha1),
        azimuth_rev_deg: alpha2_back_deg,
        iterations: iter,
    })
}

// ─────────────────────────────────────────────────────────────────────────────
// Vincenty direct
// ─────────────────────────────────────────────────────────────────────────────

/// Vincenty direct formula: destination point and reverse azimuth from a starting point,
/// forward azimuth, and distance.
///
/// # Parameters
/// - `lat1_deg`, `lon1_deg` — starting point in decimal degrees
/// - `azimuth_fwd_deg` — forward azimuth (bearing) from the starting point in degrees [0, 360)
/// - `distance_m` — geodesic distance to travel in metres
/// - `params` — ellipsoid parameters and convergence controls (see [`GeodesicParams`])
///
/// # Errors
/// Returns [`GeodesicError::InvalidInput`] if inputs are invalid.
/// Returns [`GeodesicError::AntipodalPoints`] if the direct calculation fails to converge.
pub fn vincenty_direct(
    lat1_deg: f64,
    lon1_deg: f64,
    azimuth_fwd_deg: f64,
    distance_m: f64,
    params: GeodesicParams,
) -> Result<VincentyDirectResult, GeodesicError> {
    validate_lat_lon(lat1_deg, lon1_deg, "starting point")?;
    let GeodesicParams {
        a,
        b,
        max_iter,
        tol,
    } = params;

    if !azimuth_fwd_deg.is_finite() {
        return Err(GeodesicError::InvalidInput(
            "azimuth must be a finite number".to_string(),
        ));
    }
    if !distance_m.is_finite() || distance_m < 0.0 {
        return Err(GeodesicError::InvalidInput(
            "distance must be a non-negative finite number".to_string(),
        ));
    }

    // Zero-distance short-circuit
    if distance_m < f64::EPSILON {
        return Ok(VincentyDirectResult {
            lat2_deg: lat1_deg,
            lon2_deg: lon1_deg,
            azimuth_rev_deg: (azimuth_fwd_deg + 180.0) % 360.0,
        });
    }

    let f = (a - b) / a;

    let phi1 = lat1_deg.to_radians();
    let alpha1 = azimuth_fwd_deg.to_radians();

    let sin_alpha1 = alpha1.sin();
    let cos_alpha1 = alpha1.cos();

    let one_minus_f = 1.0 - f;
    // Reduced latitude of point 1
    let tan_u1 = one_minus_f * phi1.tan();
    let cos_u1 = 1.0 / (1.0 + tan_u1 * tan_u1).sqrt();
    let sin_u1 = tan_u1 * cos_u1;

    // Angular distance on the sphere from equator to point 1
    let sigma1 = tan_u1.atan2(cos_alpha1);

    let sin_alpha = cos_u1 * sin_alpha1;
    let cos2_alpha = 1.0 - sin_alpha * sin_alpha;
    let u2_sq = cos2_alpha * (a * a - b * b) / (b * b);

    let big_a =
        1.0 + u2_sq / 16384.0 * (4096.0 + u2_sq * (-768.0 + u2_sq * (320.0 - 175.0 * u2_sq)));
    let big_b = u2_sq / 1024.0 * (256.0 + u2_sq * (-128.0 + u2_sq * (74.0 - 47.0 * u2_sq)));

    // Initial estimate for σ
    let mut sigma = distance_m / (b * big_a);
    let mut sigma_prev;
    let mut iter: u32 = 0;

    let mut cos2_sigma_m;
    let mut sin_sigma;
    let mut cos_sigma;

    loop {
        iter += 1;
        sigma_prev = sigma;

        cos2_sigma_m = (2.0 * sigma1 + sigma).cos();
        sin_sigma = sigma.sin();
        cos_sigma = sigma.cos();

        let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
        let sin_sigma_sq = sin_sigma * sin_sigma;

        let delta_sigma = big_b
            * sin_sigma
            * (cos2_sigma_m
                + big_b / 4.0
                    * (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)
                        - big_b / 6.0
                            * cos2_sigma_m
                            * (-3.0 + 4.0 * sin_sigma_sq)
                            * (-3.0 + 4.0 * cos2_sigma_m_sq)));

        sigma = distance_m / (b * big_a) + delta_sigma;

        if (sigma - sigma_prev).abs() < tol {
            break;
        }

        if iter >= max_iter {
            return Err(GeodesicError::AntipodalPoints);
        }
    }

    // Recompute final values
    cos2_sigma_m = (2.0 * sigma1 + sigma).cos();
    sin_sigma = sigma.sin();
    cos_sigma = sigma.cos();

    // Destination latitude
    let num = sin_u1 * cos_sigma + cos_u1 * sin_sigma * cos_alpha1;
    let denom = one_minus_f
        * (sin_alpha * sin_alpha + (sin_u1 * sin_sigma - cos_u1 * cos_sigma * cos_alpha1).powi(2))
            .sqrt();
    let phi2 = num.atan2(denom);

    // Longitude difference on the ellipsoid
    let lambda_num = sin_sigma * sin_alpha1;
    let lambda_den = cos_u1 * cos_sigma - sin_u1 * sin_sigma * cos_alpha1;
    let lambda_on_sphere = lambda_num.atan2(lambda_den);

    let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
    let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));

    let l = lambda_on_sphere
        - (1.0 - c)
            * f
            * sin_alpha
            * (sigma
                + c * sin_sigma * (cos2_sigma_m + c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)));

    let lon2 = lon1_deg.to_radians() + l;

    // alpha2: Vincenty geodesic bearing at destination in the forward direction.
    // The "reverse azimuth" (bearing from destination back toward origin) is alpha2 + 180°.
    let alpha2_fwd = sin_alpha.atan2(-sin_u1 * sin_sigma + cos_u1 * cos_sigma * cos_alpha1);
    let azimuth_rev_deg = (azi_to_deg(alpha2_fwd) + 180.0) % 360.0;

    Ok(VincentyDirectResult {
        lat2_deg: phi2.to_degrees(),
        lon2_deg: lon2.to_degrees(),
        azimuth_rev_deg,
    })
}

// ─────────────────────────────────────────────────────────────────────────────
// Haversine (spherical approximation)
// ─────────────────────────────────────────────────────────────────────────────

/// Haversine spherical distance between two geographic points.
///
/// Fast approximation that ignores ellipsoidal flattening. Suitable for rough
/// distance estimates; errors are up to ±0.5% compared to the geodesic distance.
///
/// # Parameters
/// - `lat1_deg`, `lon1_deg` — first point in decimal degrees
/// - `lat2_deg`, `lon2_deg` — second point in decimal degrees
/// - `radius_m` — sphere radius in metres (use `WGS84_MEAN_RADIUS` for Earth)
///
/// # Returns
/// Distance in metres.
pub fn haversine_distance_m(
    lat1_deg: f64,
    lon1_deg: f64,
    lat2_deg: f64,
    lon2_deg: f64,
    radius_m: f64,
) -> f64 {
    let phi1 = lat1_deg.to_radians();
    let phi2 = lat2_deg.to_radians();
    let delta_phi = (lat2_deg - lat1_deg).to_radians();
    let delta_lambda = (lon2_deg - lon1_deg).to_radians();

    let a = (delta_phi / 2.0).sin().powi(2)
        + phi1.cos() * phi2.cos() * (delta_lambda / 2.0).sin().powi(2);
    let c = 2.0 * a.sqrt().asin();

    radius_m * c
}

// ─────────────────────────────────────────────────────────────────────────────
// WGS84 convenience wrappers
// ─────────────────────────────────────────────────────────────────────────────

/// Vincenty inverse on the WGS84 ellipsoid (a = 6 378 137 m, b = 6 356 752.314 245 179 m).
///
/// Uses default convergence parameters (100 iterations, tolerance 1e-12).
///
/// # Errors
/// See [`vincenty_inverse`].
pub fn wgs84_inverse(
    lat1_deg: f64,
    lon1_deg: f64,
    lat2_deg: f64,
    lon2_deg: f64,
) -> Result<VincentyResult, GeodesicError> {
    vincenty_inverse(
        lat1_deg,
        lon1_deg,
        lat2_deg,
        lon2_deg,
        GeodesicParams::wgs84(),
    )
}

/// Vincenty direct on the WGS84 ellipsoid.
///
/// Uses default convergence parameters (100 iterations, tolerance 1e-12).
///
/// # Errors
/// See [`vincenty_direct`].
pub fn wgs84_direct(
    lat1_deg: f64,
    lon1_deg: f64,
    azimuth_fwd_deg: f64,
    distance_m: f64,
) -> Result<VincentyDirectResult, GeodesicError> {
    vincenty_direct(
        lat1_deg,
        lon1_deg,
        azimuth_fwd_deg,
        distance_m,
        GeodesicParams::wgs84(),
    )
}

/// Haversine distance on the WGS84 mean radius (6 371 008.8 m).
///
/// # Returns
/// Distance in metres.
pub fn wgs84_haversine_m(lat1_deg: f64, lon1_deg: f64, lat2_deg: f64, lon2_deg: f64) -> f64 {
    haversine_distance_m(lat1_deg, lon1_deg, lat2_deg, lon2_deg, WGS84_MEAN_RADIUS)
}

// ─────────────────────────────────────────────────────────────────────────────
// Unit tests (in-module)
// ─────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_coincident_points_zero_distance() {
        let result = wgs84_inverse(51.5, -0.1, 51.5, -0.1).unwrap();
        assert_eq!(result.distance_m, 0.0);
        assert_eq!(result.iterations, 0);
    }

    #[test]
    fn test_azimuth_normalisation() {
        // Normalise a negative radian azimuth
        let azi = azi_to_deg(-core::f64::consts::PI / 4.0);
        assert!((azi - 315.0).abs() < 1e-9);
    }

    #[test]
    fn test_haversine_equatorial() {
        // 1° of longitude on equator using WGS84 mean radius ≈ 111 195 m
        // (Note: using equatorial radius 6 378 137 m gives 111 319 m — different radius)
        let d = haversine_distance_m(0.0, 0.0, 0.0, 1.0, WGS84_MEAN_RADIUS);
        assert!((d - 111_195.0).abs() < 10.0);
    }

    #[test]
    fn test_validate_lat_lon_rejects_bad_lat() {
        let err = validate_lat_lon(91.0, 0.0, "pt");
        assert!(err.is_err());
    }

    #[test]
    fn test_validate_lat_lon_rejects_bad_lon() {
        let err = validate_lat_lon(0.0, 181.0, "pt");
        assert!(err.is_err());
    }

    #[test]
    fn test_wgs84_constants_consistent() {
        // f = (a - b) / a must be close to 1/298.257223563
        let f = (WGS84_A - WGS84_B) / WGS84_A;
        let expected_f = 1.0 / 298.257_223_563;
        assert!((f - expected_f).abs() < 1e-12);
    }

    #[test]
    fn test_direct_zero_distance() {
        let result = wgs84_direct(48.0, 2.0, 90.0, 0.0).unwrap();
        assert!((result.lat2_deg - 48.0).abs() < 1e-10);
        assert!((result.lon2_deg - 2.0).abs() < 1e-10);
    }
}