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