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