Skip to main content

oxigdal_proj/
geodesic.rs

1//! Vincenty geodetic distance and azimuth calculations on an arbitrary ellipsoid.
2//!
3//! Implements Vincenty's (1975) inverse and direct formulae for geodesic distance/azimuth.
4//! Reference: Vincenty, T. (1975). "Direct and inverse solutions of geodesics on the ellipsoid
5//! with application of nested equations". Survey Review 23(176):88–93.
6//!
7//! The inverse formula computes the geodesic distance and forward/reverse azimuths between two
8//! geographic coordinates. The direct formula computes the destination point and reverse azimuth
9//! given a starting point, forward azimuth, and distance.
10//!
11//! # Accuracy
12//!
13//! The Vincenty formulae are accurate to within 0.5 mm for all points on the ellipsoid except
14//! for nearly antipodal points where convergence may fail.
15//!
16//! # Examples
17//!
18//! ```
19//! use oxigdal_proj::geodesic::wgs84_inverse;
20//!
21//! let result = wgs84_inverse(51.5074, -0.1278, 48.8566, 2.3522).unwrap();
22//! println!("London-Paris: {:.0} m", result.distance_m);
23//! ```
24
25#[cfg(not(feature = "std"))]
26use alloc::string::{String, ToString};
27
28use core::fmt;
29
30// ─────────────────────────────────────────────────────────────────────────────
31// WGS84 constants
32// ─────────────────────────────────────────────────────────────────────────────
33
34/// WGS84 semi-major axis in metres.
35pub const WGS84_A: f64 = 6_378_137.0;
36
37/// WGS84 semi-minor axis in metres.
38pub const WGS84_B: f64 = 6_356_752.314_245_179;
39
40/// WGS84 mean radius used for haversine calculations (metres).
41/// Defined as (2a + b) / 3, the authalic mean radius of WGS84.
42pub const WGS84_MEAN_RADIUS: f64 = 6_371_008.8;
43
44/// Default maximum iterations for Vincenty convergence.
45pub const DEFAULT_MAX_ITER: u32 = 100;
46
47/// Default convergence tolerance for Vincenty (radians).
48pub const DEFAULT_TOL: f64 = 1e-12;
49
50// ─────────────────────────────────────────────────────────────────────────────
51// Ellipsoid parameters
52// ─────────────────────────────────────────────────────────────────────────────
53
54/// Reference ellipsoid parameters used in Vincenty geodesic computations.
55///
56/// Groups the semi-major axis, semi-minor axis, and convergence controls so that
57/// the Vincenty functions stay within the clippy `too_many_arguments` limit.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct GeodesicParams {
60    /// Semi-major axis in metres (equatorial radius).
61    pub a: f64,
62    /// Semi-minor axis in metres (polar radius).
63    pub b: f64,
64    /// Maximum number of iterations for convergence.
65    pub max_iter: u32,
66    /// Convergence tolerance in radians.
67    pub tol: f64,
68}
69
70impl GeodesicParams {
71    /// Construct custom ellipsoid parameters with default convergence settings.
72    pub fn new(a: f64, b: f64) -> Self {
73        Self {
74            a,
75            b,
76            max_iter: DEFAULT_MAX_ITER,
77            tol: DEFAULT_TOL,
78        }
79    }
80
81    /// Construct with explicit convergence controls.
82    pub fn with_convergence(a: f64, b: f64, max_iter: u32, tol: f64) -> Self {
83        Self {
84            a,
85            b,
86            max_iter,
87            tol,
88        }
89    }
90
91    /// WGS84 ellipsoid with default convergence settings.
92    pub fn wgs84() -> Self {
93        Self::new(WGS84_A, WGS84_B)
94    }
95}
96
97// ─────────────────────────────────────────────────────────────────────────────
98// Public result types
99// ─────────────────────────────────────────────────────────────────────────────
100
101/// Result of a Vincenty inverse (geodesic distance + azimuths) computation.
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct VincentyResult {
104    /// Geodesic distance in metres.
105    pub distance_m: f64,
106    /// Forward azimuth (bearing from point 1 to point 2), degrees [0, 360).
107    pub azimuth_fwd_deg: f64,
108    /// Reverse azimuth (bearing from point 2 towards point 1), degrees [0, 360).
109    pub azimuth_rev_deg: f64,
110    /// Number of iterations needed to reach convergence.
111    pub iterations: u32,
112}
113
114impl fmt::Display for VincentyResult {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(
117            f,
118            "distance={:.3} m, fwd_az={:.6}°, rev_az={:.6}°, iters={}",
119            self.distance_m, self.azimuth_fwd_deg, self.azimuth_rev_deg, self.iterations
120        )
121    }
122}
123
124/// Result of a Vincenty direct computation (destination + reverse azimuth).
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct VincentyDirectResult {
127    /// Latitude of the destination point in degrees.
128    pub lat2_deg: f64,
129    /// Longitude of the destination point in degrees.
130    pub lon2_deg: f64,
131    /// Reverse azimuth at the destination (bearing back toward origin), degrees [0, 360).
132    pub azimuth_rev_deg: f64,
133}
134
135impl fmt::Display for VincentyDirectResult {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(
138            f,
139            "lat={:.8}°, lon={:.8}°, rev_az={:.6}°",
140            self.lat2_deg, self.lon2_deg, self.azimuth_rev_deg
141        )
142    }
143}
144
145// ─────────────────────────────────────────────────────────────────────────────
146// Error type
147// ─────────────────────────────────────────────────────────────────────────────
148
149/// Errors that can occur during geodesic computations.
150#[derive(Debug, Clone, PartialEq)]
151pub enum GeodesicError {
152    /// Vincenty inverse failed to converge — points are (nearly) antipodal.
153    AntipodalPoints,
154    /// Input values are out of valid range or otherwise invalid.
155    InvalidInput(String),
156}
157
158impl fmt::Display for GeodesicError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            GeodesicError::AntipodalPoints => write!(
162                f,
163                "Vincenty inverse failed to converge: points are nearly antipodal"
164            ),
165            GeodesicError::InvalidInput(msg) => write!(f, "Invalid input: {msg}"),
166        }
167    }
168}
169
170#[cfg(feature = "std")]
171impl std::error::Error for GeodesicError {}
172
173// ─────────────────────────────────────────────────────────────────────────────
174// Internal helpers
175// ─────────────────────────────────────────────────────────────────────────────
176
177/// Normalise an azimuth in **radians** to degrees in [0, 360).
178#[inline]
179fn azi_to_deg(azi_rad: f64) -> f64 {
180    let deg = azi_rad.to_degrees();
181    (deg % 360.0 + 360.0) % 360.0
182}
183
184/// Validate geographic coordinates (degrees).
185fn validate_lat_lon(lat: f64, lon: f64, label: &str) -> Result<(), GeodesicError> {
186    if !lat.is_finite() || !lon.is_finite() {
187        return Err(GeodesicError::InvalidInput(
188            #[cfg(feature = "std")]
189            format!("{label}: lat/lon must be finite numbers (got lat={lat}, lon={lon})"),
190            #[cfg(not(feature = "std"))]
191            "lat/lon must be finite numbers".to_string(),
192        ));
193    }
194    if !(-90.0..=90.0).contains(&lat) {
195        return Err(GeodesicError::InvalidInput(
196            #[cfg(feature = "std")]
197            format!("{label}: latitude {lat} is outside [-90, 90]"),
198            #[cfg(not(feature = "std"))]
199            "latitude out of range [-90, 90]".to_string(),
200        ));
201    }
202    if !(-180.0..=180.0).contains(&lon) {
203        return Err(GeodesicError::InvalidInput(
204            #[cfg(feature = "std")]
205            format!("{label}: longitude {lon} is outside [-180, 180]"),
206            #[cfg(not(feature = "std"))]
207            "longitude out of range [-180, 180]".to_string(),
208        ));
209    }
210    Ok(())
211}
212
213// ─────────────────────────────────────────────────────────────────────────────
214// Vincenty inverse
215// ─────────────────────────────────────────────────────────────────────────────
216
217/// Vincenty inverse formula: geodesic distance and azimuths between two (lat, lon) points.
218///
219/// # Parameters
220/// - `lat1_deg`, `lon1_deg` — first point in decimal degrees
221/// - `lat2_deg`, `lon2_deg` — second point in decimal degrees
222/// - `params` — ellipsoid parameters and convergence controls (see [`GeodesicParams`])
223///
224/// # Errors
225/// Returns [`GeodesicError::AntipodalPoints`] if convergence fails (nearly antipodal case).
226/// Returns [`GeodesicError::InvalidInput`] if coordinates are out of valid range.
227pub fn vincenty_inverse(
228    lat1_deg: f64,
229    lon1_deg: f64,
230    lat2_deg: f64,
231    lon2_deg: f64,
232    params: GeodesicParams,
233) -> Result<VincentyResult, GeodesicError> {
234    validate_lat_lon(lat1_deg, lon1_deg, "point 1")?;
235    validate_lat_lon(lat2_deg, lon2_deg, "point 2")?;
236    let GeodesicParams {
237        a,
238        b,
239        max_iter,
240        tol,
241    } = params;
242
243    // Coincident-point short-circuit
244    if (lat1_deg - lat2_deg).abs() < f64::EPSILON && (lon1_deg - lon2_deg).abs() < f64::EPSILON {
245        return Ok(VincentyResult {
246            distance_m: 0.0,
247            azimuth_fwd_deg: 0.0,
248            azimuth_rev_deg: 0.0,
249            iterations: 0,
250        });
251    }
252
253    let f = (a - b) / a; // flattening
254
255    // Convert to radians
256    let phi1 = lat1_deg.to_radians();
257    let phi2 = lat2_deg.to_radians();
258    let l = (lon2_deg - lon1_deg).to_radians();
259
260    // Reduced latitudes (latitude on the auxiliary sphere)
261    let one_minus_f = 1.0 - f;
262    let u1 = (one_minus_f * phi1.tan()).atan();
263    let u2 = (one_minus_f * phi2.tan()).atan();
264
265    let sin_u1 = u1.sin();
266    let cos_u1 = u1.cos();
267    let sin_u2 = u2.sin();
268    let cos_u2 = u2.cos();
269
270    /// State captured from each Vincenty inverse iteration.
271    struct IterState {
272        sin_sigma: f64,
273        cos_sigma: f64,
274        sigma: f64,
275        cos2_alpha: f64,
276        cos2_sigma_m: f64,
277        lambda: f64,
278    }
279
280    /// Run one Vincenty inverse iteration given the current λ, returning updated state.
281    #[inline]
282    fn vincenty_iter_step(
283        lambda: f64,
284        l: f64,
285        f: f64,
286        sin_u1: f64,
287        cos_u1: f64,
288        sin_u2: f64,
289        cos_u2: f64,
290    ) -> IterState {
291        let sin_lambda = lambda.sin();
292        let cos_lambda = lambda.cos();
293
294        let term_a = cos_u2 * sin_lambda;
295        let term_b = cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda;
296        let sin_sigma = (term_a * term_a + term_b * term_b).sqrt();
297        let cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
298        let sigma = sin_sigma.atan2(cos_sigma);
299
300        let sin_alpha = if sin_sigma.abs() < f64::EPSILON {
301            0.0
302        } else {
303            cos_u1 * cos_u2 * sin_lambda / sin_sigma
304        };
305        let cos2_alpha = 1.0 - sin_alpha * sin_alpha;
306
307        let cos2_sigma_m = if cos2_alpha.abs() < f64::EPSILON {
308            0.0
309        } else {
310            cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha
311        };
312
313        let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
314        let new_lambda = l
315            + (1.0 - c)
316                * f
317                * sin_alpha
318                * (sigma
319                    + c * sin_sigma
320                        * (cos2_sigma_m
321                            + c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)));
322
323        IterState {
324            sin_sigma,
325            cos_sigma,
326            sigma,
327            cos2_alpha,
328            cos2_sigma_m,
329            lambda: new_lambda,
330        }
331    }
332
333    // Iterative solution — λ starts at L
334    let mut lambda = l;
335    let mut iter: u32 = 0;
336    let state = loop {
337        iter += 1;
338        let state = vincenty_iter_step(lambda, l, f, sin_u1, cos_u1, sin_u2, cos_u2);
339        let delta = (state.lambda - lambda).abs();
340        lambda = state.lambda;
341
342        if delta < tol {
343            break state;
344        }
345
346        if iter >= max_iter {
347            return Err(GeodesicError::AntipodalPoints);
348        }
349    };
350
351    let sin_sigma = state.sin_sigma;
352    let cos_sigma = state.cos_sigma;
353    let sigma = state.sigma;
354    let cos2_alpha = state.cos2_alpha;
355    let cos2_sigma_m = state.cos2_sigma_m;
356    lambda = state.lambda;
357
358    // Post-convergence: compute distance
359    let u2_sq = cos2_alpha * (a * a - b * b) / (b * b);
360
361    let big_a =
362        1.0 + u2_sq / 16384.0 * (4096.0 + u2_sq * (-768.0 + u2_sq * (320.0 - 175.0 * u2_sq)));
363    let big_b = u2_sq / 1024.0 * (256.0 + u2_sq * (-128.0 + u2_sq * (74.0 - 47.0 * u2_sq)));
364
365    let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
366    let sin_sigma_sq = sin_sigma * sin_sigma;
367
368    let delta_sigma = big_b
369        * sin_sigma
370        * (cos2_sigma_m
371            + big_b / 4.0
372                * (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)
373                    - big_b / 6.0
374                        * cos2_sigma_m
375                        * (-3.0 + 4.0 * sin_sigma_sq)
376                        * (-3.0 + 4.0 * cos2_sigma_m_sq)));
377
378    let distance = b * big_a * (sigma - delta_sigma);
379
380    // Azimuths
381    // alpha1 = forward azimuth at P1 (bearing from P1 toward P2)
382    // alpha2 = Vincenty's geodesic azimuth at P2 in the *forward* direction of travel.
383    //          The "reverse azimuth" (bearing from P2 back to P1) is alpha2 + 180°.
384    let sin_lambda = lambda.sin();
385    let cos_lambda = lambda.cos();
386
387    let alpha1 = (cos_u2 * sin_lambda).atan2(cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda);
388    let alpha2_fwd = (cos_u1 * sin_lambda).atan2(-sin_u1 * cos_u2 + cos_u1 * sin_u2 * cos_lambda);
389
390    // Convert alpha2 to the back-bearing (from P2 toward P1)
391    let alpha2_back_deg = (azi_to_deg(alpha2_fwd) + 180.0) % 360.0;
392
393    Ok(VincentyResult {
394        distance_m: distance,
395        azimuth_fwd_deg: azi_to_deg(alpha1),
396        azimuth_rev_deg: alpha2_back_deg,
397        iterations: iter,
398    })
399}
400
401// ─────────────────────────────────────────────────────────────────────────────
402// Vincenty direct
403// ─────────────────────────────────────────────────────────────────────────────
404
405/// Vincenty direct formula: destination point and reverse azimuth from a starting point,
406/// forward azimuth, and distance.
407///
408/// # Parameters
409/// - `lat1_deg`, `lon1_deg` — starting point in decimal degrees
410/// - `azimuth_fwd_deg` — forward azimuth (bearing) from the starting point in degrees [0, 360)
411/// - `distance_m` — geodesic distance to travel in metres
412/// - `params` — ellipsoid parameters and convergence controls (see [`GeodesicParams`])
413///
414/// # Errors
415/// Returns [`GeodesicError::InvalidInput`] if inputs are invalid.
416/// Returns [`GeodesicError::AntipodalPoints`] if the direct calculation fails to converge.
417pub fn vincenty_direct(
418    lat1_deg: f64,
419    lon1_deg: f64,
420    azimuth_fwd_deg: f64,
421    distance_m: f64,
422    params: GeodesicParams,
423) -> Result<VincentyDirectResult, GeodesicError> {
424    validate_lat_lon(lat1_deg, lon1_deg, "starting point")?;
425    let GeodesicParams {
426        a,
427        b,
428        max_iter,
429        tol,
430    } = params;
431
432    if !azimuth_fwd_deg.is_finite() {
433        return Err(GeodesicError::InvalidInput(
434            "azimuth must be a finite number".to_string(),
435        ));
436    }
437    if !distance_m.is_finite() || distance_m < 0.0 {
438        return Err(GeodesicError::InvalidInput(
439            "distance must be a non-negative finite number".to_string(),
440        ));
441    }
442
443    // Zero-distance short-circuit
444    if distance_m < f64::EPSILON {
445        return Ok(VincentyDirectResult {
446            lat2_deg: lat1_deg,
447            lon2_deg: lon1_deg,
448            azimuth_rev_deg: (azimuth_fwd_deg + 180.0) % 360.0,
449        });
450    }
451
452    let f = (a - b) / a;
453
454    let phi1 = lat1_deg.to_radians();
455    let alpha1 = azimuth_fwd_deg.to_radians();
456
457    let sin_alpha1 = alpha1.sin();
458    let cos_alpha1 = alpha1.cos();
459
460    let one_minus_f = 1.0 - f;
461    // Reduced latitude of point 1
462    let tan_u1 = one_minus_f * phi1.tan();
463    let cos_u1 = 1.0 / (1.0 + tan_u1 * tan_u1).sqrt();
464    let sin_u1 = tan_u1 * cos_u1;
465
466    // Angular distance on the sphere from equator to point 1
467    let sigma1 = tan_u1.atan2(cos_alpha1);
468
469    let sin_alpha = cos_u1 * sin_alpha1;
470    let cos2_alpha = 1.0 - sin_alpha * sin_alpha;
471    let u2_sq = cos2_alpha * (a * a - b * b) / (b * b);
472
473    let big_a =
474        1.0 + u2_sq / 16384.0 * (4096.0 + u2_sq * (-768.0 + u2_sq * (320.0 - 175.0 * u2_sq)));
475    let big_b = u2_sq / 1024.0 * (256.0 + u2_sq * (-128.0 + u2_sq * (74.0 - 47.0 * u2_sq)));
476
477    // Initial estimate for σ
478    let mut sigma = distance_m / (b * big_a);
479    let mut sigma_prev;
480    let mut iter: u32 = 0;
481
482    let mut cos2_sigma_m;
483    let mut sin_sigma;
484    let mut cos_sigma;
485
486    loop {
487        iter += 1;
488        sigma_prev = sigma;
489
490        cos2_sigma_m = (2.0 * sigma1 + sigma).cos();
491        sin_sigma = sigma.sin();
492        cos_sigma = sigma.cos();
493
494        let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
495        let sin_sigma_sq = sin_sigma * sin_sigma;
496
497        let delta_sigma = big_b
498            * sin_sigma
499            * (cos2_sigma_m
500                + big_b / 4.0
501                    * (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)
502                        - big_b / 6.0
503                            * cos2_sigma_m
504                            * (-3.0 + 4.0 * sin_sigma_sq)
505                            * (-3.0 + 4.0 * cos2_sigma_m_sq)));
506
507        sigma = distance_m / (b * big_a) + delta_sigma;
508
509        if (sigma - sigma_prev).abs() < tol {
510            break;
511        }
512
513        if iter >= max_iter {
514            return Err(GeodesicError::AntipodalPoints);
515        }
516    }
517
518    // Recompute final values
519    cos2_sigma_m = (2.0 * sigma1 + sigma).cos();
520    sin_sigma = sigma.sin();
521    cos_sigma = sigma.cos();
522
523    // Destination latitude
524    let num = sin_u1 * cos_sigma + cos_u1 * sin_sigma * cos_alpha1;
525    let denom = one_minus_f
526        * (sin_alpha * sin_alpha + (sin_u1 * sin_sigma - cos_u1 * cos_sigma * cos_alpha1).powi(2))
527            .sqrt();
528    let phi2 = num.atan2(denom);
529
530    // Longitude difference on the ellipsoid
531    let lambda_num = sin_sigma * sin_alpha1;
532    let lambda_den = cos_u1 * cos_sigma - sin_u1 * sin_sigma * cos_alpha1;
533    let lambda_on_sphere = lambda_num.atan2(lambda_den);
534
535    let cos2_sigma_m_sq = cos2_sigma_m * cos2_sigma_m;
536    let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
537
538    let l = lambda_on_sphere
539        - (1.0 - c)
540            * f
541            * sin_alpha
542            * (sigma
543                + c * sin_sigma * (cos2_sigma_m + c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m_sq)));
544
545    let lon2 = lon1_deg.to_radians() + l;
546
547    // alpha2: Vincenty geodesic bearing at destination in the forward direction.
548    // The "reverse azimuth" (bearing from destination back toward origin) is alpha2 + 180°.
549    let alpha2_fwd = sin_alpha.atan2(-sin_u1 * sin_sigma + cos_u1 * cos_sigma * cos_alpha1);
550    let azimuth_rev_deg = (azi_to_deg(alpha2_fwd) + 180.0) % 360.0;
551
552    Ok(VincentyDirectResult {
553        lat2_deg: phi2.to_degrees(),
554        lon2_deg: lon2.to_degrees(),
555        azimuth_rev_deg,
556    })
557}
558
559// ─────────────────────────────────────────────────────────────────────────────
560// Haversine (spherical approximation)
561// ─────────────────────────────────────────────────────────────────────────────
562
563/// Haversine spherical distance between two geographic points.
564///
565/// Fast approximation that ignores ellipsoidal flattening. Suitable for rough
566/// distance estimates; errors are up to ±0.5% compared to the geodesic distance.
567///
568/// # Parameters
569/// - `lat1_deg`, `lon1_deg` — first point in decimal degrees
570/// - `lat2_deg`, `lon2_deg` — second point in decimal degrees
571/// - `radius_m` — sphere radius in metres (use `WGS84_MEAN_RADIUS` for Earth)
572///
573/// # Returns
574/// Distance in metres.
575pub fn haversine_distance_m(
576    lat1_deg: f64,
577    lon1_deg: f64,
578    lat2_deg: f64,
579    lon2_deg: f64,
580    radius_m: f64,
581) -> f64 {
582    let phi1 = lat1_deg.to_radians();
583    let phi2 = lat2_deg.to_radians();
584    let delta_phi = (lat2_deg - lat1_deg).to_radians();
585    let delta_lambda = (lon2_deg - lon1_deg).to_radians();
586
587    let a = (delta_phi / 2.0).sin().powi(2)
588        + phi1.cos() * phi2.cos() * (delta_lambda / 2.0).sin().powi(2);
589    let c = 2.0 * a.sqrt().asin();
590
591    radius_m * c
592}
593
594// ─────────────────────────────────────────────────────────────────────────────
595// WGS84 convenience wrappers
596// ─────────────────────────────────────────────────────────────────────────────
597
598/// Vincenty inverse on the WGS84 ellipsoid (a = 6 378 137 m, b = 6 356 752.314 245 179 m).
599///
600/// Uses default convergence parameters (100 iterations, tolerance 1e-12).
601///
602/// # Errors
603/// See [`vincenty_inverse`].
604pub fn wgs84_inverse(
605    lat1_deg: f64,
606    lon1_deg: f64,
607    lat2_deg: f64,
608    lon2_deg: f64,
609) -> Result<VincentyResult, GeodesicError> {
610    vincenty_inverse(
611        lat1_deg,
612        lon1_deg,
613        lat2_deg,
614        lon2_deg,
615        GeodesicParams::wgs84(),
616    )
617}
618
619/// Vincenty direct on the WGS84 ellipsoid.
620///
621/// Uses default convergence parameters (100 iterations, tolerance 1e-12).
622///
623/// # Errors
624/// See [`vincenty_direct`].
625pub fn wgs84_direct(
626    lat1_deg: f64,
627    lon1_deg: f64,
628    azimuth_fwd_deg: f64,
629    distance_m: f64,
630) -> Result<VincentyDirectResult, GeodesicError> {
631    vincenty_direct(
632        lat1_deg,
633        lon1_deg,
634        azimuth_fwd_deg,
635        distance_m,
636        GeodesicParams::wgs84(),
637    )
638}
639
640/// Haversine distance on the WGS84 mean radius (6 371 008.8 m).
641///
642/// # Returns
643/// Distance in metres.
644pub fn wgs84_haversine_m(lat1_deg: f64, lon1_deg: f64, lat2_deg: f64, lon2_deg: f64) -> f64 {
645    haversine_distance_m(lat1_deg, lon1_deg, lat2_deg, lon2_deg, WGS84_MEAN_RADIUS)
646}
647
648// ─────────────────────────────────────────────────────────────────────────────
649// Unit tests (in-module)
650// ─────────────────────────────────────────────────────────────────────────────
651
652#[cfg(test)]
653#[allow(clippy::unwrap_used)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn test_coincident_points_zero_distance() {
659        let result = wgs84_inverse(51.5, -0.1, 51.5, -0.1).unwrap();
660        assert_eq!(result.distance_m, 0.0);
661        assert_eq!(result.iterations, 0);
662    }
663
664    #[test]
665    fn test_azimuth_normalisation() {
666        // Normalise a negative radian azimuth
667        let azi = azi_to_deg(-core::f64::consts::PI / 4.0);
668        assert!((azi - 315.0).abs() < 1e-9);
669    }
670
671    #[test]
672    fn test_haversine_equatorial() {
673        // 1° of longitude on equator using WGS84 mean radius ≈ 111 195 m
674        // (Note: using equatorial radius 6 378 137 m gives 111 319 m — different radius)
675        let d = haversine_distance_m(0.0, 0.0, 0.0, 1.0, WGS84_MEAN_RADIUS);
676        assert!((d - 111_195.0).abs() < 10.0);
677    }
678
679    #[test]
680    fn test_validate_lat_lon_rejects_bad_lat() {
681        let err = validate_lat_lon(91.0, 0.0, "pt");
682        assert!(err.is_err());
683    }
684
685    #[test]
686    fn test_validate_lat_lon_rejects_bad_lon() {
687        let err = validate_lat_lon(0.0, 181.0, "pt");
688        assert!(err.is_err());
689    }
690
691    #[test]
692    fn test_wgs84_constants_consistent() {
693        // f = (a - b) / a must be close to 1/298.257223563
694        let f = (WGS84_A - WGS84_B) / WGS84_A;
695        let expected_f = 1.0 / 298.257_223_563;
696        assert!((f - expected_f).abs() < 1e-12);
697    }
698
699    #[test]
700    fn test_direct_zero_distance() {
701        let result = wgs84_direct(48.0, 2.0, 90.0, 0.0).unwrap();
702        assert!((result.lat2_deg - 48.0).abs() < 1e-10);
703        assert!((result.lon2_deg - 2.0).abs() < 1e-10);
704    }
705}