Skip to main content

geometry_strategy/geographic/
distance_vincenty.rs

1//! Vincenty inverse geodesic distance on a reference spheroid.
2//!
3//! Mirrors `boost::geometry::strategy::distance::vincenty<Spheroid, T>`
4//! from `strategies/geographic/distance_vincenty.hpp`. The underlying
5//! iterative inverse formula lives in
6//! `boost/geometry/formulas/vincenty_inverse.hpp` — see Vincenty (1975)
7//! and the references collected in Boost's doc-block at
8//! `formulas/vincenty_inverse.hpp:38-47`. Vincenty is slower than
9//! Andoyer (it iterates λ until convergence) but is the gold-standard
10//! ellipsoidal geodesic on non-antipodal inputs.
11//!
12//! # Calculation-type policy
13//!
14//! Like [`Andoyer`](super::Andoyer), this implementation hardcodes
15//! `Scalar = f64` on both inputs (the T40 / T43 convention). The
16//! kernel reaches for `f64::sin` / `cos` / `atan2` / `sqrt` directly
17//! without growing the [`CoordinateScalar`](geometry_coords::CoordinateScalar)
18//! trait surface; mixed-scalar support folds in alongside the
19//! `Promote` lattice once a real caller appears.
20//!
21//! `#[cfg(feature = "std")]` gates the impl: the standard library
22//! provides the trig and `sqrt` functions as inherent methods on `f64`.
23//! A `no_std` build of `geometry-strategy` (default-features off) does
24//! not get Vincenty; that mirrors the same gate Andoyer / Haversine
25//! use.
26//!
27//! # Comparable form
28//!
29//! There is no useful "skip the sqrt" form of Vincenty — the kernel
30//! is dominated by trig calls and an iterative refinement rather than
31//! a trailing `sqrt`. We follow Boost
32//! (`strategies/geographic/distance_vincenty.hpp:85-89`) and set
33//! `type Comparable = Self;`.
34//!
35//! # Default strategy slot
36//!
37//! Vincenty deliberately does *not* implement
38//! `DefaultDistance<GeographicFamily>` — Andoyer holds that slot
39//! (matches Boost's `services::default_strategy` for the geographic
40//! tag). Callers opt into Vincenty via
41//! `geometry_algorithm::distance_with(a, b, Vincenty::WGS84)`.
42
43use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
44use geometry_tag::SameAs;
45use geometry_trait::Point;
46
47use crate::distance::DistanceStrategy;
48
49#[cfg(feature = "std")]
50use crate::geographic::spheroid_calc::SpheroidCalc;
51#[cfg(feature = "std")]
52use crate::normalise::{HasAngularUnits, lonlat_radians};
53
54/// Vincenty inverse geodesic distance on a reference spheroid.
55///
56/// Inputs follow the [`Geographic<U>`](geometry_cs::Geographic)
57/// equatorial convention — see its rustdoc.
58///
59/// Mirrors `boost::geometry::strategy::distance::vincenty<Spheroid, T>`
60/// from `strategies/geographic/distance_vincenty.hpp:42-66`. The
61/// spheroid is supplied at construction and the output is in metres
62/// (or whatever units the spheroid's equatorial radius is expressed
63/// in).
64///
65/// The iterative arithmetic mirrors
66/// `boost::geometry::formula::vincenty_inverse::apply` from
67/// `formulas/vincenty_inverse.hpp:67-188` — the distance-only branch
68/// (`EnableDistance = true`, all other flags false).
69#[derive(Debug, Clone, Copy)]
70pub struct Vincenty {
71    /// Reference ellipsoid the distance is measured on.
72    pub spheroid: Spheroid,
73    /// Maximum iterations before bailing out near antipodal points.
74    /// Boost's default is 1000 — see
75    /// `BOOST_GEOMETRY_DETAIL_VINCENTY_MAX_STEPS` at
76    /// `formulas/vincenty_inverse.hpp:30-32`.
77    pub max_iterations: u32,
78    /// Convergence threshold on λ in radians. Boost hardcodes
79    /// `c_e_12 = 1e-12` at `formulas/vincenty_inverse.hpp:87`.
80    pub tolerance: f64,
81}
82
83impl Vincenty {
84    /// Vincenty parameterised by the WGS84 reference ellipsoid — the
85    /// default ellipsoid for nearly every real geographic dataset.
86    /// Matches the default-constructed `srs::spheroid<RadiusType>`
87    /// Boost uses when `vincenty<>` is built without arguments
88    /// (`strategies/geographic/distance_vincenty.hpp:59-61`).
89    pub const WGS84: Self = Self {
90        spheroid: Spheroid::WGS84,
91        max_iterations: 1000,
92        tolerance: 1e-12,
93    };
94}
95
96impl Default for Vincenty {
97    #[inline]
98    fn default() -> Self {
99        Self::WGS84
100    }
101}
102
103// ---- DistanceStrategy impl ------------------------------------------
104//
105// The `SameAs<GeographicFamily>` bounds on both points enforce the
106// geographic-only rule. A caller wiring a Cartesian or Spherical point
107// through here by mistake gets the `#[diagnostic::on_unimplemented]`
108// plate on `geometry_tag::SameAs` pointing them at
109// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
110// strategies; that is the same redirect plate Andoyer / Haversine
111// rely on.
112
113/// Vincenty on `f64` geographic points.
114///
115/// Mirrors `formula::vincenty_inverse<CT, true, false>::apply` at
116/// `formulas/vincenty_inverse.hpp:67-188` — the distance-only branch.
117/// Iterates λ on the auxiliary sphere until either successive λ
118/// differ by less than `self.tolerance`, `|λ|` exits the (−π, π)
119/// interval, or `self.max_iterations` is reached, then evaluates the
120/// arc length on the ellipsoid via the standard A / B / Δσ series.
121///
122/// # Diagnostics on mis-paired CS
123///
124/// A caller who pairs a Cartesian or Spherical point with [`Vincenty`]
125/// hits the `<P::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>`
126/// bound below and gets the redirect plate on
127/// [`geometry_tag::SameAs`] pointing them at
128/// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
129/// strategies. See T31 and proposal §3.7.
130#[cfg(feature = "std")]
131impl<P1, P2> DistanceStrategy<P1, P2> for Vincenty
132where
133    P1: Point<Scalar = f64>,
134    P2: Point<Scalar = f64>,
135    P1::Cs: HasAngularUnits,
136    P2::Cs: HasAngularUnits,
137    <P1::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
138    <P2::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
139{
140    type Out = f64;
141    type Comparable = Self;
142
143    // `many_single_char_names`, `float_cmp`, `similar_names`: the
144    // single-letter names `A, B, C, L, u_sq`, the `sin_sigma` /
145    // `sin2_sigma` / `cos_2sigma_m` / `cos2_2sigma_m` family, and the
146    // exact `== same-lonlat` short-circuit mirror
147    // `formula::vincenty_inverse::apply` in
148    // `formulas/vincenty_inverse.hpp:76-187` letter-for-letter; the
149    // exact-equality early-out is the intentional analogue of Boost's
150    // `math::equals` against the same inputs at lines 76-79.
151    #[allow(
152        clippy::many_single_char_names,
153        clippy::float_cmp,
154        clippy::similar_names
155    )]
156    #[inline]
157    fn distance(&self, a: &P1, b: &P2) -> Self::Out {
158        let calc = SpheroidCalc::from(self.spheroid);
159        let (lon1, lat1) = lonlat_radians(a);
160        let (lon2, lat2) = lonlat_radians(b);
161
162        // Mirrors the `math::equals(lat1, lat2) && math::equals(lon1, lon2)`
163        // short-circuit at `formulas/vincenty_inverse.hpp:76-79`.
164        if lon1 == lon2 && lat1 == lat2 {
165            return 0.0;
166        }
167
168        let pi = core::f64::consts::PI;
169        let two_pi = 2.0 * pi;
170
171        // λ: difference in longitude on an auxiliary sphere. Mirrors
172        // `formulas/vincenty_inverse.hpp:92-97`.
173        let mut big_l = lon2 - lon1;
174        if big_l < -pi {
175            big_l += two_pi;
176        }
177        if big_l > pi {
178            big_l -= two_pi;
179        }
180        let mut lambda = big_l;
181
182        let f = calc.f;
183        let a_radius = calc.a;
184        let b_radius = calc.b;
185
186        // U: reduced latitude, tan U = (1 − f) tan φ. Mirrors
187        // `formulas/vincenty_inverse.hpp:103-117`.
188        let one_min_f = 1.0 - f;
189        let tan_u1 = one_min_f * lat1.tan();
190        let tan_u2 = one_min_f * lat2.tan();
191
192        let cos_u1 = 1.0 / (1.0 + tan_u1 * tan_u1).sqrt();
193        let cos_u2 = 1.0 / (1.0 + tan_u2 * tan_u2).sqrt();
194        let sin_u1 = tan_u1 * cos_u1;
195        let sin_u2 = tan_u2 * cos_u2;
196
197        // Iteration state carried out of the do-while loop. Mirrors
198        // the declarations at `formulas/vincenty_inverse.hpp:127-137`.
199        // The loop runs at least once (do-while shape), so these are
200        // always written before they are read after the break.
201        let mut sin_sigma;
202        let mut cos_sigma;
203        let mut sigma;
204        let mut sin_alpha;
205        let mut cos2_alpha;
206        let mut cos_2sigma_m;
207        let mut cos2_2sigma_m;
208
209        // do-while loop from `formulas/vincenty_inverse.hpp:139-160`.
210        // We translate the `do { … } while (cond)` directly into a
211        // `loop { …; if !cond { break; } }` to keep the iteration
212        // count and the break predicate aligned with the C++.
213        let mut counter: u32 = 0;
214        loop {
215            let previous_lambda = lambda;
216            let sin_lambda = lambda.sin();
217            let cos_lambda = lambda.cos();
218
219            // (14) sin σ
220            let sx = cos_u2 * sin_lambda;
221            let sy = cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda;
222            sin_sigma = (sx * sx + sy * sy).sqrt();
223
224            // (15) cos σ
225            cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
226
227            // (17) sin α
228            sin_alpha = if sin_sigma == 0.0 {
229                0.0
230            } else {
231                cos_u1 * cos_u2 * sin_lambda / sin_sigma
232            };
233            cos2_alpha = 1.0 - sin_alpha * sin_alpha;
234
235            // (18) cos 2σ_m — guard the equatorial line (cos²α == 0)
236            // exactly as `formulas/vincenty_inverse.hpp:148`.
237            cos_2sigma_m = if cos2_alpha == 0.0 {
238                0.0
239            } else {
240                cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha
241            };
242            cos2_2sigma_m = cos_2sigma_m * cos_2sigma_m;
243
244            // (10) C
245            let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
246
247            // (16) σ
248            sigma = sin_sigma.atan2(cos_sigma);
249
250            // (11) λ ← L + (1 − C) f sinα (σ + C sinσ (cos 2σ_m + C cosσ (−1 + 2 cos² 2σ_m)))
251            lambda = big_l
252                + (1.0 - c)
253                    * f
254                    * sin_alpha
255                    * (sigma
256                        + c * sin_sigma
257                            * (cos_2sigma_m + c * cos_sigma * (-1.0 + 2.0 * cos2_2sigma_m)));
258
259            counter += 1;
260
261            // Termination matches `formulas/vincenty_inverse.hpp:158-160`:
262            //   * converged on λ (Δλ ≤ tolerance), or
263            //   * |λ| escaped the principal branch (anti-meridian
264            //     wrap — near-antipodal stress case, handled by the
265            //     meridian-fallback ladder in M5), or
266            //   * iteration cap hit.
267            let converged = (previous_lambda - lambda).abs() <= self.tolerance;
268            if converged || lambda.abs() >= pi || counter >= self.max_iterations {
269                break;
270            }
271        }
272
273        // u² = cos²α · ((a/b)² − 1). Mirrors `formulas/vincenty_inverse.hpp:178`.
274        let a_over_b = a_radius / b_radius;
275        let u_sq = cos2_alpha * (a_over_b * a_over_b - 1.0);
276
277        // (3) A
278        let big_a =
279            1.0 + u_sq / 16384.0 * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)));
280        // (4) B
281        let big_b = u_sq / 1024.0 * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)));
282
283        let cos_sigma_final = sigma.cos();
284        let sin2_sigma = sin_sigma * sin_sigma;
285
286        // (6) Δσ
287        let delta_sigma = big_b
288            * sin_sigma
289            * (cos_2sigma_m
290                + (big_b / 4.0)
291                    * (cos_sigma_final * (-1.0 + 2.0 * cos_2sigma_m * cos_2sigma_m)
292                        - (big_b / 6.0)
293                            * cos_2sigma_m
294                            * (-3.0 + 4.0 * sin2_sigma)
295                            * (-3.0 + 4.0 * cos_2sigma_m * cos_2sigma_m)));
296
297        // (19) s = b · A · (σ − Δσ)
298        b_radius * big_a * (sigma - delta_sigma)
299    }
300
301    #[inline]
302    fn comparable(&self) -> Self::Comparable {
303        *self
304    }
305}
306
307// ---- Tests ----------------------------------------------------------
308
309#[cfg(all(test, feature = "std"))]
310#[allow(
311    clippy::doc_markdown,
312    reason = "doc-comments quote `1_336_039.890` etc. — Rust numeric \
313              literal syntax which clippy flags as missing backticks; \
314              wrapping them in extra backticks hurts readability for \
315              what is plainly a numeric value with its units."
316)]
317mod tests {
318    //! Reference values come from `geometry/test/strategies/vincenty.cpp`
319    //! — the cases below cite the exact lines in that file. The Boost
320    //! tests use the GDA spheroid (`a = 6378.1370 km`,
321    //! `f = 1 / 298.25722210`) at `vincenty.cpp:246-249`.
322
323    use super::Vincenty;
324    use crate::distance::DistanceStrategy;
325    use geometry_adapt::{Adapt, WithCs};
326    use geometry_cs::{Degree, Geographic, Spheroid};
327
328    type GP = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
329
330    #[inline]
331    fn deg(lon: f64, lat: f64) -> GP {
332        WithCs::new(Adapt([lon, lat]))
333    }
334
335    /// GDA spheroid used by `vincenty.cpp:246-249`.
336    const GDA: Spheroid = Spheroid {
337        equatorial_radius: 6_378_137.0,
338        flattening: 1.0 / 298.257_222_10,
339    };
340
341    fn gda_vincenty() -> Vincenty {
342        Vincenty {
343            spheroid: GDA,
344            ..Vincenty::WGS84
345        }
346    }
347
348    /// `vincenty.cpp:280` — N: `(0, 0) → (0, 50)` = 5_540_847.042 m.
349    #[test]
350    fn meridian_north_50_degrees() {
351        let d = gda_vincenty().distance(&deg(0.0, 0.0), &deg(0.0, 50.0));
352        assert!(
353            (d - 5_540_847.042).abs() < 1e-2,
354            "got {d} m, expected ~ 5_540_847.042 m"
355        );
356    }
357
358    /// `vincenty.cpp:282` — E: `(0, 0) → (50, 0)` = 5_565_974.540 m.
359    #[test]
360    fn equator_east_50_degrees() {
361        let d = gda_vincenty().distance(&deg(0.0, 0.0), &deg(50.0, 0.0));
362        assert!(
363            (d - 5_565_974.540).abs() < 1e-2,
364            "got {d} m, expected ~ 5_565_974.540 m"
365        );
366    }
367
368    /// `vincenty.cpp:285` — NE: `(0, 0) → (50, 50)` = 7_284_879.297 m.
369    #[test]
370    fn northeast_50_50() {
371        let d = gda_vincenty().distance(&deg(0.0, 0.0), &deg(50.0, 50.0));
372        assert!(
373            (d - 7_284_879.297).abs() < 1e-2,
374            "got {d} m, expected ~ 7_284_879.297 m"
375        );
376    }
377
378    /// `vincenty.cpp:287-289` — sub-polar: `(0, 89) → (1, 80)` =
379    /// 1_005_153.576_9 m. The `test_vincenty` invocations at
380    /// `vincenty.cpp:289-291` deliberately omit the GDA spheroid
381    /// argument — the in-source comment at line 287 reads
382    /// *"Using default spheroid units (meters)"*, so these three
383    /// reference values are on Boost's default `srs::spheroid<double>`,
384    /// which is WGS84.
385    ///
386    /// Boost compares with `BOOST_CHECK_CLOSE(..., tolerance)` where
387    /// `tolerance = 0.001` is a *percent* (not absolute) bound
388    /// (`vincenty.cpp:114-115, 138`). 0.001% of 1_005_153 m ≈ 10 m,
389    /// which is the bound we apply here.
390    #[test]
391    fn sub_polar() {
392        let d = Vincenty::WGS84.distance(&deg(0.0, 89.0), &deg(1.0, 80.0));
393        let tolerance = 1_005_153.576_9 * 0.001 / 100.0;
394        assert!(
395            (d - 1_005_153.576_9).abs() < tolerance,
396            "got {d} m, expected ~ 1_005_153.576_9 m (tol {tolerance} m)"
397        );
398    }
399
400    /// `vincenty.cpp:290` — identity (no distance) doesn't blow up.
401    #[test]
402    fn identity_zero() {
403        let p = deg(4.0, 52.0);
404        let d = gda_vincenty().distance(&p, &p);
405        assert!(d.abs() < 1e-6, "got {d}");
406    }
407
408    /// `vincenty.cpp:291` — normal: `(4, 52) → (3, 40)` =
409    /// 1_336_039.890 m. Same default-spheroid (WGS84) caveat as
410    /// [`sub_polar`] above — see `vincenty.cpp:287` comment.
411    ///
412    /// The 1_336_039.890 m reference matches Andoyer to the metre
413    /// (see `andoyer.cpp:230-231`); Vincenty for this pair on WGS84
414    /// works out to ~1_336_027 m. Boost's actual assertion is
415    /// `BOOST_CHECK_CLOSE` with `tolerance = 0.001%` of the reference
416    /// (`vincenty.cpp:114-115, 138`) — 0.001% of 1_336_040 m ≈ 13 m,
417    /// which is the bound we apply here.
418    #[test]
419    fn lon_4_lat_52_to_lon_3_lat_40() {
420        let d = Vincenty::WGS84.distance(&deg(4.0, 52.0), &deg(3.0, 40.0));
421        let tolerance = 1_336_039.890 * 0.001 / 100.0;
422        assert!(
423            (d - 1_336_039.890).abs() < tolerance,
424            "got {d} m, expected ~ 1_336_039.890 m (tol {tolerance} m)"
425        );
426    }
427
428    /// `vincenty.cpp:264-267` — Lodz → Trondheim ≈ 1_399_032.724 m
429    /// (Boost reports 1399.032724 km).
430    #[test]
431    fn lodz_to_trondheim() {
432        let lodz = deg(19.0 + 28.0 / 60.0, 51.0 + 47.0 / 60.0);
433        let trondheim = deg(10.0 + 21.0 / 60.0, 63.0 + 23.0 / 60.0);
434        let d = gda_vincenty().distance(&lodz, &trondheim);
435        assert!(
436            (d - 1_399_032.724).abs() < 1.0,
437            "got {d} m, expected ~ 1_399_032.724 m"
438        );
439    }
440
441    /// `vincenty.cpp:269-272` — London → New York ≈ 5_602_044.851 m.
442    #[test]
443    fn london_to_new_york() {
444        let london = deg(
445            0.0 + 7.0 / 60.0 + 39.0 / 3600.0,
446            51.0 + 30.0 / 60.0 + 26.0 / 3600.0,
447        );
448        let nyc = deg(
449            -(74.0 + 0.0 / 60.0 + 21.0 / 3600.0),
450            40.0 + 42.0 / 60.0 + 46.0 / 3600.0,
451        );
452        let d = gda_vincenty().distance(&london, &nyc);
453        assert!(
454            (d - 5_602_044.851).abs() < 1.0,
455            "got {d} m, expected ~ 5_602_044.851 m"
456        );
457    }
458
459    /// Vincenty's default constructor selects WGS84 — mirrors Boost's
460    /// `vincenty()` no-arg constructor at
461    /// `strategies/geographic/distance_vincenty.hpp:59-61`.
462    #[test]
463    fn default_is_wgs84() {
464        let v = Vincenty::default();
465        let w = Vincenty::WGS84;
466        assert_eq!(v.spheroid, w.spheroid);
467        assert_eq!(v.max_iterations, w.max_iterations);
468        assert!((v.tolerance - w.tolerance).abs() < 1e-30);
469    }
470
471    // KC1.T2 witness: proves this strategy accepts a read-only `Point`
472    // (one that need not implement `PointMut`). If it compiles, the
473    // read-only bound is locked.
474    fn _accepts_readonly_point<P, S>(s: &S, a: &P, b: &P) -> S::Out
475    where
476        P: geometry_trait::Point,
477        S: DistanceStrategy<P, P>,
478    {
479        s.distance(a, b)
480    }
481
482    /// `comparable()` returns a strategy producing the same distance.
483    #[test]
484    fn comparable_produces_the_same_distance() {
485        let a = deg(4.0, 52.0);
486        let b = deg(3.0, 40.0);
487        let real = Vincenty::WGS84.distance(&a, &b);
488        let cmp = DistanceStrategy::<GP, GP>::comparable(&Vincenty::WGS84).distance(&a, &b);
489        assert!((real - cmp).abs() < 1e-9);
490    }
491
492    /// The read-only-point witness computes a distance when invoked.
493    #[test]
494    #[allow(
495        clippy::used_underscore_items,
496        reason = "the test exists to run the compile-time witness's body"
497    )]
498    fn readonly_witness_computes_distance() {
499        let d = _accepts_readonly_point(&Vincenty::WGS84, &deg(4.0, 52.0), &deg(3.0, 40.0));
500        assert!(d > 1_000_000.0, "≈1336 km, got {d}");
501    }
502}