Skip to main content

geometry_strategy/geographic/
distance_andoyer.rs

1//! Andoyer–Lambert geographic distance on a reference spheroid.
2//!
3//! Mirrors `boost::geometry::strategy::distance::andoyer<Spheroid, T>`
4//! from `strategies/geographic/distance_andoyer.hpp`. The underlying
5//! arithmetic comes from `formulas/andoyer_inverse.hpp` — a
6//! Forsyth–Andoyer–Lambert first-order spheroidal correction to the
7//! spherical great-circle distance. Boost's commentary on the header
8//! notes that the approximation is accurate to within a few metres of
9//! Vincenty in all tested cases.
10//!
11//! # Calculation-type policy
12//!
13//! Boost runs the inputs through
14//! `util::calculation_type::geographic::binary` and picks a working
15//! scalar; the v1 Rust port follows Haversine's approach (T40 spec) and
16//! hardcodes `Scalar = f64` on both inputs. This lets the kernel reach
17//! for `f64::sin` / `cos` / `acos` / `sqrt` directly without growing
18//! the [`CoordinateScalar`](geometry_coords::CoordinateScalar) trait
19//! surface. Mixed-scalar support folds in alongside the `Promote`
20//! lattice when a real caller appears.
21//!
22//! `#[cfg(feature = "std")]` gates the impl: the standard library
23//! provides the trig and `sqrt` functions as inherent methods on `f64`.
24//! A `no_std` build of `geometry-strategy` (default-features off) does
25//! not get Andoyer; that mirrors the same gate Haversine uses.
26//!
27//! # Comparable form
28//!
29//! Andoyer has no useful "skip the sqrt" form — the corrections
30//! involve `acos` and additive flattening terms that cannot be shed
31//! while preserving ordering. We follow Boost
32//! (`strategies/geographic/distance_andoyer.hpp:91-94`) and set
33//! `type Comparable = Self;`.
34
35use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
36use geometry_tag::SameAs;
37use geometry_trait::Point;
38
39use crate::distance::{DefaultDistance, DistanceStrategy};
40
41#[cfg(feature = "std")]
42use crate::geographic::spheroid_calc::SpheroidCalc;
43#[cfg(feature = "std")]
44use crate::normalise::{HasAngularUnits, lonlat_radians};
45
46/// Andoyer–Lambert geographic distance on a reference spheroid.
47///
48/// Inputs follow the [`Geographic<U>`](geometry_cs::Geographic)
49/// equatorial convention — see its rustdoc.
50///
51/// Mirrors `boost::geometry::strategy::distance::andoyer<Spheroid, T>`
52/// from `strategies/geographic/distance_andoyer.hpp:46-70`. The
53/// spheroid is supplied at construction and the output is in metres
54/// (or whatever units the spheroid's equatorial radius is expressed
55/// in).
56///
57/// The underlying arithmetic mirrors
58/// `boost::geometry::formula::andoyer_inverse::apply` from
59/// `formulas/andoyer_inverse.hpp:58-123`.
60#[derive(Debug, Clone, Copy)]
61pub struct Andoyer {
62    /// Reference ellipsoid the distance is measured on.
63    pub spheroid: Spheroid,
64}
65
66impl Andoyer {
67    /// Andoyer parameterised by the WGS84 reference ellipsoid — the
68    /// default for nearly every real geographic dataset. Matches the
69    /// default-constructed `srs::spheroid<RadiusType>` Boost uses when
70    /// `andoyer<>` is built without arguments
71    /// (`strategies/geographic/distance_andoyer.hpp:63-65`).
72    pub const WGS84: Self = Self {
73        spheroid: Spheroid::WGS84,
74    };
75}
76
77impl Default for Andoyer {
78    #[inline]
79    fn default() -> Self {
80        Self::WGS84
81    }
82}
83
84// ---- DistanceStrategy impl ------------------------------------------
85//
86// The `SameAs<GeographicFamily>` bounds on both points enforce the
87// geographic-only rule. A caller wiring a Cartesian or Spherical point
88// through here by mistake gets the `#[diagnostic::on_unimplemented]`
89// plate on `geometry_tag::SameAs` pointing them at
90// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
91// strategies; that is the same redirect plate Haversine relies on.
92
93/// Andoyer on `f64` geographic points.
94///
95/// Mirrors `formula::andoyer_inverse<CT, true, false>::apply` at
96/// `formulas/andoyer_inverse.hpp:58-123` — the distance-only branch
97/// (`EnableDistance = true`, all other flags false). Computes:
98///
99/// ```text
100/// cos_d  = sin(lat1)·sin(lat2) + cos(lat1)·cos(lat2)·cos(Δlon)
101/// d      = acos(cos_d)
102/// K      = (sin(lat1) − sin(lat2))²
103/// L      = (sin(lat1) + sin(lat2))²
104/// H      = (d + 3·sin_d) / (1 − cos_d)
105/// G      = (d − 3·sin_d) / (1 + cos_d)
106/// dd     = −(f/4) · (H·K + G·L)
107/// result = a · (d + dd)
108/// ```
109///
110/// with the degenerate `1 ± cos_d == 0` branches falling back to
111/// `H = 0` / `G = 0` as in
112/// `formulas/andoyer_inverse.hpp:111-117`.
113///
114/// # Diagnostics on mis-paired CS
115///
116/// A caller who pairs a Cartesian or Spherical point with [`Andoyer`]
117/// hits the `<P::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>`
118/// bound below and gets the redirect plate on
119/// [`geometry_tag::SameAs`] pointing them at
120/// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
121/// strategies. See T31 and proposal §3.7.
122#[cfg(feature = "std")]
123impl<P1, P2> DistanceStrategy<P1, P2> for Andoyer
124where
125    P1: Point<Scalar = f64>,
126    P2: Point<Scalar = f64>,
127    P1::Cs: HasAngularUnits,
128    P2::Cs: HasAngularUnits,
129    <P1::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
130    <P2::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
131{
132    type Out = f64;
133    type Comparable = Self;
134
135    // `many_single_char_names`, `float_cmp`: the single-letter names
136    // `d, dd, h, g, k, l` and the exact `== 0.0` / `== same-lonlat`
137    // checks mirror `formula::andoyer_inverse::apply` in
138    // `formulas/andoyer_inverse.hpp:74-122` letter-for-letter; the
139    // exact-equality short-circuit is the intentional analogue of
140    // Boost's `math::equals` against zero on the same line numbers.
141    #[allow(clippy::many_single_char_names, clippy::float_cmp)]
142    #[inline]
143    fn distance(&self, a: &P1, b: &P2) -> Self::Out {
144        let calc = SpheroidCalc::from(self.spheroid);
145        let (lon1, lat1) = lonlat_radians(a);
146        let (lon2, lat2) = lonlat_radians(b);
147
148        // Mirrors the `math::equals(lon1, lon2) && math::equals(lat1, lat2)`
149        // short-circuit at `formulas/andoyer_inverse.hpp:69-72`.
150        if lon1 == lon2 && lat1 == lat2 {
151            return 0.0;
152        }
153
154        let dlon = lon2 - lon1;
155        let cos_dlon = dlon.cos();
156        let sin_lat1 = lat1.sin();
157        let cos_lat1 = lat1.cos();
158        let sin_lat2 = lat2.sin();
159        let cos_lat2 = lat2.cos();
160
161        // Spherical great-circle term, clamped to [-1, 1] to defend
162        // against rounding pushing `cos_d` slightly outside the
163        // acos domain — same defence as
164        // `formulas/andoyer_inverse.hpp:90-95`.
165        let cos_d = (sin_lat1 * sin_lat2 + cos_lat1 * cos_lat2 * cos_dlon).clamp(-1.0, 1.0);
166
167        let d = cos_d.acos();
168        let sin_d = d.sin();
169
170        // Andoyer–Lambert flattening correction. Mirrors
171        // `formulas/andoyer_inverse.hpp:102-122`.
172        let k = (sin_lat1 - sin_lat2) * (sin_lat1 - sin_lat2);
173        let l = (sin_lat1 + sin_lat2) * (sin_lat1 + sin_lat2);
174        let three_sin_d = 3.0 * sin_d;
175
176        let one_minus_cos_d = 1.0 - cos_d;
177        let one_plus_cos_d = 1.0 + cos_d;
178
179        // `cos_d == 1` ⇒ near-coincident, `cos_d == -1` ⇒ antipodal.
180        // Boost guards `H` / `G` against the singular denominators
181        // with `math::equals(..., c0)` — we use exact `== 0.0`
182        // because the trig of finite inputs that escaped the
183        // earlier `lon1 == lon2 && lat1 == lat2` short-circuit can
184        // only land on exactly 0.0 in pathological cases.
185        let h = if one_minus_cos_d == 0.0 {
186            0.0
187        } else {
188            (d + three_sin_d) / one_minus_cos_d
189        };
190        let g = if one_plus_cos_d == 0.0 {
191            0.0
192        } else {
193            (d - three_sin_d) / one_plus_cos_d
194        };
195
196        let dd = -(calc.f / 4.0) * (h * k + g * l);
197
198        calc.a * (d + dd)
199    }
200
201    #[inline]
202    fn comparable(&self) -> Self::Comparable {
203        *self
204    }
205}
206
207// ---- Default Geographic × Geographic = Andoyer ----------------------
208
209/// Geographic × Geographic defaults to Andoyer.
210///
211/// Mirrors the `services::default_strategy<point_tag, point_tag, P1,
212/// P2, geographic_tag, geographic_tag>` specialisation in
213/// `strategies/geographic/distance.hpp` — Boost picks
214/// `strategy::distance::geographic<strategy::andoyer, Spheroid>` as
215/// the geographic default, which is exactly
216/// `strategy::distance::andoyer<Spheroid>`.
217impl DefaultDistance<GeographicFamily> for GeographicFamily {
218    type Strategy = Andoyer;
219}
220
221// ---- Tests ----------------------------------------------------------
222
223#[cfg(all(test, feature = "std"))]
224mod tests {
225    //! Reference values come from
226    //! `geometry/test/strategies/andoyer.cpp` — the cases below cite
227    //! the exact lines in that file.
228
229    use super::Andoyer;
230    use crate::distance::DistanceStrategy;
231    use geometry_adapt::{Adapt, WithCs};
232    use geometry_cs::{Degree, Geographic};
233
234    type GP = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
235
236    #[inline]
237    fn deg(lon: f64, lat: f64) -> GP {
238        WithCs::new(Adapt([lon, lat]))
239    }
240
241    /// `test/strategies/andoyer.cpp:222-223` — polar case:
242    /// `(0, 90) → (1, 80) ≈ 1116.814 km`.
243    #[test]
244    fn polar_1deg_lon_10deg_lat() {
245        let d = Andoyer::WGS84.distance(&deg(0.0, 90.0), &deg(1.0, 80.0));
246        assert!((d / 1000.0 - 1_116.814_237).abs() < 0.01);
247    }
248
249    /// `test/strategies/andoyer.cpp:226-227` — zero distance on equal
250    /// points.
251    #[test]
252    fn zero_distance_on_equal_points() {
253        let p = deg(4.0, 52.0);
254        let d = Andoyer::WGS84.distance(&p, &p);
255        assert!(d.abs() < 1e-3, "got {d}");
256    }
257
258    /// `test/strategies/andoyer.cpp:230-231` — normal case:
259    /// `(4, 52) → (3, 40) ≈ 1336.040 km`.
260    #[test]
261    fn lon_4_lat_52_to_lon_3_lat_40() {
262        let d = Andoyer::WGS84.distance(&deg(4.0, 52.0), &deg(3.0, 40.0));
263        assert!((d / 1000.0 - 1_336.039_890).abs() < 0.01);
264    }
265
266    /// `test/strategies/andoyer.cpp:243-246` — four antipodal
267    /// equatorial pairs.
268    ///
269    /// **Divergence from Boost's expected `20_003.9 km`:** the Boost
270    /// test value is produced by the *strategy*
271    /// `strategy::distance::geographic<andoyer>` at
272    /// `strategies/geographic/distance.hpp:91-112`, which first runs
273    /// `formula::meridian_inverse` and only falls back to
274    /// `andoyer_inverse` for non-meridian pairs. For the four
275    /// equatorial-antipodal cases here `|Δlon| == 180°` triggers the
276    /// meridian shortcut. T43's scope is `andoyer_inverse` itself —
277    /// the meridian / nearly-antipodal fallback ladder is M5 (T46) —
278    /// so we feed the inputs directly through `andoyer_inverse`,
279    /// which (correctly) reports the equatorial circumference / 2 ≈
280    /// `20_037.5 km`. Tolerance is 1 km against that value.
281    #[test]
282    fn antipodal_equatorial() {
283        // 2π · a / 2 for WGS84 ≈ 20_037.508 km.
284        let expected_km = core::f64::consts::PI * 6_378_137.0 / 1000.0;
285        for (a, b) in [
286            (deg(0.0, 0.0), deg(180.0, 0.0)),
287            (deg(0.0, 0.0), deg(-180.0, 0.0)),
288            (deg(-90.0, 0.0), deg(90.0, 0.0)),
289            (deg(90.0, 0.0), deg(-90.0, 0.0)),
290        ] {
291            let d = Andoyer::WGS84.distance(&a, &b);
292            assert!((d / 1000.0 - expected_km).abs() < 1.0);
293        }
294    }
295
296    /// Andoyer's default constructor selects WGS84 — mirrors Boost's
297    /// `andoyer()` no-arg constructor at
298    /// `strategies/geographic/distance_andoyer.hpp:63-65`.
299    #[test]
300    fn default_is_wgs84() {
301        let a = Andoyer::default();
302        let w = Andoyer::WGS84;
303        assert_eq!(a.spheroid, w.spheroid);
304    }
305
306    // KC1.T2 witness: proves this strategy accepts a read-only `Point`
307    // (one that need not implement `PointMut`). If it compiles, the
308    // read-only bound is locked.
309    fn _accepts_readonly_point<P, S>(s: &S, a: &P, b: &P) -> S::Out
310    where
311        P: geometry_trait::Point,
312        S: DistanceStrategy<P, P>,
313    {
314        s.distance(a, b)
315    }
316
317    /// `comparable()` returns a strategy producing the same distance —
318    /// there is no sqrt to skip in the geodesic formula.
319    #[test]
320    fn comparable_produces_the_same_distance() {
321        let a = deg(4.0, 52.0);
322        let b = deg(3.0, 40.0);
323        let real = Andoyer::WGS84.distance(&a, &b);
324        let cmp = DistanceStrategy::<GP, GP>::comparable(&Andoyer::WGS84).distance(&a, &b);
325        assert!((real - cmp).abs() < 1e-9);
326    }
327
328    /// The read-only-point witness computes a distance when invoked.
329    #[test]
330    #[allow(
331        clippy::used_underscore_items,
332        reason = "the test exists to run the compile-time witness's body"
333    )]
334    fn readonly_witness_computes_distance() {
335        let d = _accepts_readonly_point(&Andoyer::WGS84, &deg(4.0, 52.0), &deg(3.0, 40.0));
336        assert!(d > 1_000_000.0, "≈1336 km, got {d}");
337    }
338}