Skip to main content

geometry_strategy/geographic/
distance_thomas.rs

1//! Thomas geographic distance on a reference spheroid.
2//!
3//! Mirrors `boost::geometry::strategy::distance::thomas<Spheroid, T>`
4//! from `strategies/geographic/distance_thomas.hpp`. The underlying
5//! arithmetic comes from `formulas/thomas_inverse.hpp` — a
6//! Forsyth–Andoyer–Lambert type approximation with second-order
7//! terms (Paul D. Thomas, 1965 / 1970).
8//!
9//! Thomas sits between Andoyer (first-order) and Vincenty (iterative
10//! exact) on the speed / accuracy curve: faster than Vincenty,
11//! noticeably more precise than Andoyer on long lines.
12//!
13//! # Calculation-type policy
14//!
15//! Following Andoyer (T43) and Haversine (T40), we hardcode
16//! `Scalar = f64` on both inputs so the kernel reaches for `f64::sin`
17//! / `cos` / `acos` / `tan` / `atan` directly without growing the
18//! [`CoordinateScalar`](geometry_coords::CoordinateScalar) trait
19//! surface.
20//!
21//! `#[cfg(feature = "std")]` gates the impl: the standard library
22//! provides the trig functions as inherent methods on `f64`. A
23//! `no_std` build of `geometry-strategy` (default-features off) does
24//! not get Thomas; that mirrors the same gate Andoyer / Haversine
25//! use.
26//!
27//! # Comparable form
28//!
29//! Thomas has no useful "skip the sqrt" form — like Andoyer, the
30//! corrections involve `acos` and additive flattening terms that
31//! cannot be shed while preserving ordering. We follow Boost
32//! (`strategies/geographic/distance_thomas.hpp:83-87`) and set
33//! `type Comparable = Self;`.
34//!
35//! # Default-strategy registration
36//!
37//! Andoyer is the Geographic × Geographic default; Thomas is
38//! explicitly opt-in. We deliberately do **not** implement
39//! [`DefaultDistance`](crate::distance::DefaultDistance) here.
40
41use geometry_cs::{CoordinateSystem, GeographicFamily, Spheroid};
42use geometry_tag::SameAs;
43use geometry_trait::Point;
44
45use crate::distance::DistanceStrategy;
46
47#[cfg(feature = "std")]
48use crate::geographic::spheroid_calc::SpheroidCalc;
49#[cfg(feature = "std")]
50use crate::normalise::{HasAngularUnits, lonlat_radians};
51
52/// Thomas geographic distance on a reference spheroid.
53///
54/// Inputs follow the [`Geographic<U>`](geometry_cs::Geographic)
55/// equatorial convention — see its rustdoc.
56///
57/// Mirrors `boost::geometry::strategy::distance::thomas<Spheroid, T>`
58/// from `strategies/geographic/distance_thomas.hpp:46-64`. The
59/// spheroid is supplied at construction and the output is in metres
60/// (or whatever units the spheroid's equatorial radius is expressed
61/// in).
62///
63/// The underlying arithmetic mirrors
64/// `boost::geometry::formula::thomas_inverse::apply` from
65/// `formulas/thomas_inverse.hpp:59-215` — the distance-only branch
66/// (`EnableDistance = true`, all azimuth / reduced-length / scale
67/// flags false).
68///
69/// # Antipodal / pole-to-pole limitation
70///
71/// Like Boost's raw `thomas_inverse`, this strategy returns **`0.0`**
72/// for antipodal or pole-to-pole endpoints: at a central angle of `π`
73/// the series' `sin(d)` term vanishes and the formula degenerates
74/// (`formulas/thomas_inverse.hpp:120-125` returns a zero distance).
75/// Boost's higher-level geographic strategy masks this with a
76/// `meridian_inverse` fallback ladder; that fallback is deferred here,
77/// so a caller measuring a near-antipodal line with `Thomas` gets `0`
78/// silently. Use [`Andoyer`](super::Andoyer) (no such degeneracy) or
79/// [`Haversine`](crate::spherical::Haversine) for antipodal inputs.
80#[derive(Debug, Clone, Copy)]
81pub struct Thomas {
82    /// Reference ellipsoid the distance is measured on.
83    pub spheroid: Spheroid,
84}
85
86impl Thomas {
87    /// Thomas parameterised by the WGS84 reference ellipsoid — the
88    /// default for nearly every real geographic dataset. Matches the
89    /// default-constructed `srs::spheroid<RadiusType>` Boost uses when
90    /// `thomas<>` is built without arguments
91    /// (`strategies/geographic/distance_thomas.hpp:57-59`).
92    pub const WGS84: Self = Self {
93        spheroid: Spheroid::WGS84,
94    };
95}
96
97impl Default for Thomas {
98    #[inline]
99    fn default() -> Self {
100        Self::WGS84
101    }
102}
103
104// ---- DistanceStrategy impl ------------------------------------------
105//
106// The `SameAs<GeographicFamily>` bounds on both points enforce the
107// geographic-only rule. A caller wiring a Cartesian or Spherical point
108// through here by mistake gets the `#[diagnostic::on_unimplemented]`
109// plate on `geometry_tag::SameAs` pointing them at
110// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
111// strategies; that is the same redirect plate Andoyer / Haversine
112// rely on.
113
114/// Thomas on `f64` geographic points.
115///
116/// Mirrors `formula::thomas_inverse<CT, true, false>::apply` at
117/// `formulas/thomas_inverse.hpp:59-215` — the distance-only branch
118/// (`EnableDistance = true`, all other flags false). The full
119/// derivation lives in Paul D. Thomas, *Mathematical Models for
120/// Navigation Systems*, 1965, and *Spheroidal Geodesics, Reference
121/// Systems, and Local Geometry*, 1970.
122///
123/// # Diagnostics on mis-paired CS
124///
125/// A caller who pairs a Cartesian or Spherical point with [`Thomas`]
126/// hits the `<P::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>`
127/// bound below and gets the redirect plate on
128/// [`geometry_tag::SameAs`] pointing them at
129/// `WithCs<_, Geographic<…>>` or at the Cartesian / Spherical
130/// strategies. See T31 and proposal §3.7.
131#[cfg(feature = "std")]
132impl<P1, P2> DistanceStrategy<P1, P2> for Thomas
133where
134    P1: Point<Scalar = f64>,
135    P2: Point<Scalar = f64>,
136    P1::Cs: HasAngularUnits,
137    P2::Cs: HasAngularUnits,
138    <P1::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
139    <P2::Cs as CoordinateSystem>::Family: SameAs<GeographicFamily>,
140{
141    type Out = f64;
142    type Comparable = Self;
143
144    // `many_single_char_names`, `float_cmp`: the single-letter names
145    // `H, L, U, V, X, Y, T, D, E, A, B, C, F, G, M, Q` and the exact
146    // `== 0.0` / `== same-lonlat` checks mirror
147    // `formula::thomas_inverse::apply` in
148    // `formulas/thomas_inverse.hpp:69-153` letter-for-letter; the
149    // exact-equality short-circuit is the intentional analogue of
150    // Boost's `math::equals` against zero on the same line numbers.
151    #[allow(
152        clippy::many_single_char_names,
153        clippy::similar_names,
154        clippy::float_cmp
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(lon1, lon2) && math::equals(lat1, lat2)`
163        // short-circuit at `formulas/thomas_inverse.hpp:70-73`.
164        if lon1 == lon2 && lat1 == lat2 {
165            return 0.0;
166        }
167
168        let f = calc.f;
169        let one_minus_f = 1.0 - f;
170
171        let pi_half = core::f64::consts::FRAC_PI_2;
172
173        // Reduced latitudes θ = atan((1 − f) · tan(lat)), with the
174        // pole short-circuit from `formulas/thomas_inverse.hpp:89-94`.
175        let theta1 = if lat1 == pi_half || lat1 == -pi_half {
176            lat1
177        } else {
178            (one_minus_f * lat1.tan()).atan()
179        };
180        let theta2 = if lat2 == pi_half || lat2 == -pi_half {
181            lat2
182        } else {
183            (one_minus_f * lat2.tan()).atan()
184        };
185
186        let theta_m = f64::midpoint(theta1, theta2);
187        let d_theta_m = (theta2 - theta1) / 2.0;
188        let d_lambda = lon2 - lon1;
189        let d_lambda_m = d_lambda / 2.0;
190
191        let sin_theta_m = theta_m.sin();
192        let cos_theta_m = theta_m.cos();
193        let sin_d_theta_m = d_theta_m.sin();
194        let cos_d_theta_m = d_theta_m.cos();
195        let sin2_theta_m = sin_theta_m * sin_theta_m;
196        let cos2_theta_m = cos_theta_m * cos_theta_m;
197        let sin2_d_theta_m = sin_d_theta_m * sin_d_theta_m;
198        let cos2_d_theta_m = cos_d_theta_m * cos_d_theta_m;
199        let sin_d_lambda_m = d_lambda_m.sin();
200        let sin2_d_lambda_m = sin_d_lambda_m * sin_d_lambda_m;
201
202        // Auxiliary quantities. Mirrors
203        // `formulas/thomas_inverse.hpp:112-116`.
204        let upper_h = cos2_theta_m - sin2_d_theta_m;
205        let l_term = sin2_d_theta_m + upper_h * sin2_d_lambda_m;
206        let cos_d = (1.0 - 2.0 * l_term).clamp(-1.0, 1.0);
207        let d = cos_d.acos();
208        let sin_d = d.sin();
209
210        let one_minus_l = 1.0 - l_term;
211
212        // Degenerate guards from `formulas/thomas_inverse.hpp:120-125`:
213        // `sin_d == 0` ⇒ coincident / antipodal where d=0 or π;
214        // `L == 0` or `1 − L == 0` would divide by zero below.
215        if sin_d == 0.0 || l_term == 0.0 || one_minus_l == 0.0 {
216            return 0.0;
217        }
218
219        let upper_u = 2.0 * sin2_theta_m * cos2_d_theta_m / one_minus_l;
220        let upper_v = 2.0 * sin2_d_theta_m * cos2_theta_m / l_term;
221        let upper_x = upper_u + upper_v;
222        let upper_y = upper_u - upper_v;
223        let upper_t = d / sin_d;
224        let upper_d = 4.0 * upper_t * upper_t;
225        let upper_e = 2.0 * cos_d;
226        let upper_a = upper_d * upper_e;
227        let upper_b = 2.0 * upper_d;
228        let upper_c = upper_t - (upper_a - upper_e) / 2.0;
229
230        let f_sqr = f * f;
231        let f_sqr_per_64 = f_sqr / 64.0;
232
233        // Distance branch. Mirrors
234        // `formulas/thomas_inverse.hpp:141-154`.
235        let n1 = upper_x * (upper_a + upper_c * upper_x);
236        let n2 = upper_y * (upper_b + upper_e * upper_y);
237        let n3 = upper_d * upper_x * upper_y;
238
239        let delta1d = f * (upper_t * upper_x - upper_y) / 4.0;
240        let delta2d = f_sqr_per_64 * (n1 - n2 + n3);
241
242        calc.a * sin_d * (upper_t - delta1d + delta2d)
243    }
244
245    #[inline]
246    fn comparable(&self) -> Self::Comparable {
247        *self
248    }
249}
250
251// ---- Tests ----------------------------------------------------------
252
253#[cfg(all(test, feature = "std"))]
254mod tests {
255    //! Reference values come from
256    //! `geometry/test/strategies/thomas.cpp:107-112`. The Boost test
257    //! uses `BOOST_CHECK_CLOSE(..., 0.001)` (0.001%) — we match the
258    //! same relative tolerance here, which works out to a few metres
259    //! on a 1000 km baseline.
260    //!
261    //! We also cross-check against Andoyer on the normal mid-latitude
262    //! case to confirm the second-order Thomas correction stays
263    //! within a sensible band of Andoyer's first-order result.
264
265    use super::Thomas;
266    use crate::distance::DistanceStrategy;
267    use crate::geographic::Andoyer;
268    use geometry_adapt::{Adapt, WithCs};
269    use geometry_cs::{Degree, Geographic};
270
271    type GP = WithCs<Adapt<[f64; 2]>, Geographic<Degree>>;
272
273    #[inline]
274    fn deg(lon: f64, lat: f64) -> GP {
275        WithCs::new(Adapt([lon, lat]))
276    }
277
278    /// `test/strategies/thomas.cpp:109` — polar case
279    /// `(0, 90) → (1, 80) ≈ 1116.825795 km`.
280    #[test]
281    fn polar_north_1deg_lon_10deg_lat() {
282        let d = Thomas::WGS84.distance(&deg(0.0, 90.0), &deg(1.0, 80.0));
283        // Boost's BOOST_CHECK_CLOSE(_, 0.001) ≈ 0.001 % → ~11 m here.
284        assert!((d / 1000.0 - 1_116.825_795).abs() < 0.012);
285    }
286
287    /// `test/strategies/thomas.cpp:110` — southern polar mirror
288    /// `(0, -90) → (1, -80) ≈ 1116.825795 km`.
289    #[test]
290    fn polar_south_1deg_lon_10deg_lat() {
291        let d = Thomas::WGS84.distance(&deg(0.0, -90.0), &deg(1.0, -80.0));
292        assert!((d / 1000.0 - 1_116.825_795).abs() < 0.012);
293    }
294
295    /// `test/strategies/thomas.cpp:111` — zero distance on equal
296    /// points, mirroring the
297    /// `math::equals(lon1, lon2) && math::equals(lat1, lat2)`
298    /// short-circuit at `formulas/thomas_inverse.hpp:70-73`.
299    #[test]
300    fn zero_distance_on_equal_points() {
301        let p = deg(4.0, 52.0);
302        let d = Thomas::WGS84.distance(&p, &p);
303        assert!(d.abs() < 1e-6, "got {d}");
304    }
305
306    /// `test/strategies/thomas.cpp:112` — normal case:
307    /// `(4, 52) → (3, 40) ≈ 1336.025365 km`.
308    #[test]
309    fn lon_4_lat_52_to_lon_3_lat_40() {
310        let d = Thomas::WGS84.distance(&deg(4.0, 52.0), &deg(3.0, 40.0));
311        // Boost's BOOST_CHECK_CLOSE(_, 0.001) ≈ 0.001 % → ~13 m here.
312        assert!((d / 1000.0 - 1_336.025_365).abs() < 0.014);
313    }
314
315    /// Cross-check: Thomas (second-order) and Andoyer (first-order)
316    /// should disagree by only tens of metres on a normal mid-latitude
317    /// 1336 km line. Bigger gaps would flag a coding error in the
318    /// second-order correction translated from
319    /// `formulas/thomas_inverse.hpp:141-153`.
320    #[test]
321    fn thomas_within_50m_of_andoyer_amsterdam_to_madrid() {
322        let a = deg(4.0, 52.0);
323        let b = deg(3.0, 40.0);
324        let t = Thomas::WGS84.distance(&a, &b);
325        let an = Andoyer::WGS84.distance(&a, &b);
326        assert!((t - an).abs() < 50.0);
327    }
328
329    /// Thomas's default constructor selects WGS84 — mirrors Boost's
330    /// `thomas()` no-arg constructor at
331    /// `strategies/geographic/distance_thomas.hpp:57-59`.
332    #[test]
333    fn default_is_wgs84() {
334        let a = Thomas::default();
335        let w = Thomas::WGS84;
336        assert_eq!(a.spheroid, w.spheroid);
337    }
338
339    // KC1.T2 witness: proves this strategy accepts a read-only `Point`
340    // (one that need not implement `PointMut`). If it compiles, the
341    // read-only bound is locked.
342    fn _accepts_readonly_point<P, S>(s: &S, a: &P, b: &P) -> S::Out
343    where
344        P: geometry_trait::Point,
345        S: DistanceStrategy<P, P>,
346    {
347        s.distance(a, b)
348    }
349
350    /// `comparable()` returns a strategy producing the same value — the
351    /// geodesic formulas have no sqrt to skip, so the comparable form is
352    /// the strategy itself.
353    #[test]
354    fn comparable_produces_the_same_distance() {
355        let a = deg(4.0, 52.0);
356        let b = deg(3.0, 40.0);
357        let real = Thomas::WGS84.distance(&a, &b);
358        let cmp = DistanceStrategy::<GP, GP>::comparable(&Thomas::WGS84).distance(&a, &b);
359        assert!((real - cmp).abs() < 1e-9);
360    }
361
362    /// The read-only-point witness computes a distance when invoked.
363    #[test]
364    #[allow(
365        clippy::used_underscore_items,
366        reason = "the test exists to run the compile-time witness's body"
367    )]
368    fn readonly_witness_computes_distance() {
369        let d = _accepts_readonly_point(&Thomas::WGS84, &deg(4.0, 52.0), &deg(3.0, 40.0));
370        assert!(d > 1_000_000.0, "≈1336 km, got {d}");
371    }
372}