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(°(0.0, 0.0), °(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(°(0.0, 0.0), °(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(°(0.0, 0.0), °(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(°(0.0, 89.0), °(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(°(4.0, 52.0), °(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, °(4.0, 52.0), °(3.0, 40.0));
500 assert!(d > 1_000_000.0, "≈1336 km, got {d}");
501 }
502}