geometry_strategy/spherical/distance_haversine.rs
1//! Haversine great-circle distance for the Spherical family.
2//!
3//! Mirrors `boost/geometry/strategies/spherical/distance_haversine.hpp`:
4//!
5//! * lines 44-57 — the formula commentary in the C++ header:
6//! `d = 2·asin(sqrt(sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)))`,
7//! with the inner term `h` factored out so the "comparable" form can
8//! skip both the `sqrt` and the `asin` (both monotone-increasing on
9//! `[0, 1]`).
10//! * lines 63-130 — `comparable::haversine<RadiusOrSphere, CalcType>`:
11//! returns only the inner `h` — the Rust port is
12//! [`ComparableHaversine`].
13//! * lines 146-200 — `haversine<RadiusOrSphere, CalcType>`: the
14//! sqrt-and-asin-paying companion that multiplies through by the
15//! sphere radius — the Rust port is [`Haversine`].
16//! * lines 222-260 — the `services::*` specialisations that wire the
17//! strategy into the default-strategy lookup; reproduced here as
18//! `impl DefaultDistance<SphericalFamily> for SphericalFamily`.
19//!
20//! # Convention
21//!
22//! Boost has two spherical tags — `spherical_polar_tag` (colatitude,
23//! measured from the pole) and `spherical_equatorial_tag` (latitude,
24//! measured from the equator). The Rust port collapses both onto a
25//! single [`Spherical<U>`](geometry_cs::Spherical) following the
26//! *equatorial* convention, which is what the quickstart and the
27//! Boost haversine tests use. See proposal §8.
28//!
29//! # Calculation-type policy
30//!
31//! Boost runs the inputs through
32//! `util::calculation_type::geometric::binary` to pick a working
33//! scalar (`boost/geometry/util/calculation_type.hpp`). The v1 Rust
34//! port follows the T40 spec's "for simplicity" branch and hardcodes
35//! `Scalar = f64` on both inputs — this lets the kernel reach for
36//! `f64::sin` / `cos` / `asin` directly (which require `std`) without
37//! growing the [`CoordinateScalar`](geometry_coords::CoordinateScalar)
38//! trait surface. Mixed-scalar support folds in alongside the
39//! `Promote` lattice when a real caller appears.
40//!
41//! `#[cfg(feature = "std")]` gates the impls: the standard library
42//! provides the trig functions as inherent methods on `f64`. A
43//! `no_std` build of `geometry-strategy` (default-features off) does
44//! not get Haversine; that is fine — the crate compiles, just without
45//! this strategy. T42+ may add a `libm` fallback alongside the
46//! geographic strategies.
47
48use geometry_cs::{CoordinateSystem, SphericalFamily};
49use geometry_tag::SameAs;
50use geometry_trait::Point;
51
52use crate::distance::{DefaultDistance, DistanceStrategy};
53
54#[cfg(feature = "std")]
55use crate::normalise::{HasAngularUnits, lonlat_radians};
56
57/// Haversine great-circle distance, parameterised by sphere radius.
58///
59/// Inputs follow the [`Spherical<U>`](geometry_cs::Spherical)
60/// equatorial convention — see its rustdoc.
61///
62/// Mirrors `boost::geometry::strategy::distance::haversine<T>` from
63/// `strategies/spherical/distance_haversine.hpp:146-200`. The radius
64/// is supplied at construction and the output is in the *same units
65/// as the radius* (metres for [`Haversine::EARTH`], miles if the
66/// caller multiplies a [`Haversine::UNIT`] result by a miles-radius,
67/// and so on — see the quickstart at `doc/src/examples/quick_start.cpp:137-148`).
68///
69/// The associated [`DistanceStrategy::Comparable`] type is
70/// [`ComparableHaversine`] — it returns only the inner `h` term of
71/// the formula, skipping the `sqrt` *and* the `asin` (both monotone
72/// on `[0, 1]`), mirroring
73/// `boost::geometry::strategy::distance::comparable::haversine`
74/// (`distance_haversine.hpp:63-130`).
75#[derive(Debug, Clone, Copy)]
76pub struct Haversine {
77 /// Sphere radius. The result of [`Haversine::distance`] is in
78 /// these units.
79 pub radius: f64,
80}
81
82impl Haversine {
83 /// Mean Earth radius in metres — Boost's
84 /// `average_earth_radius = 6_372_795.0` from
85 /// `test/strategies/haversine.cpp:29`. Used as the [`Default`].
86 pub const EARTH: Self = Self {
87 radius: 6_372_795.0,
88 };
89
90 /// Unit sphere (`radius = 1`): the result of
91 /// [`Haversine::distance`] is then the *angular* distance in
92 /// radians. Matches the pattern in
93 /// `doc/src/examples/quick_start.cpp:137-148`, where the
94 /// quickstart multiplies a unit-sphere angle by an Earth radius
95 /// expressed in miles to get distance in miles.
96 pub const UNIT: Self = Self { radius: 1.0 };
97}
98
99impl Default for Haversine {
100 #[inline]
101 fn default() -> Self {
102 Self::EARTH
103 }
104}
105
106/// Comparable form of [`Haversine`].
107///
108/// Inputs follow the [`Spherical<U>`](geometry_cs::Spherical)
109/// equatorial convention — see its rustdoc.
110///
111/// Returns only the inner `h` term
112/// of the haversine formula
113/// (`h = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)`), skipping
114/// the `sqrt`, the `asin`, and the radius multiply. Ordering matches
115/// [`Haversine`] because both `sqrt` and `asin` are monotone on
116/// `[0, 1]`.
117///
118/// Mirrors `boost::geometry::strategy::distance::comparable::haversine`
119/// from `strategies/spherical/distance_haversine.hpp:63-130`.
120#[derive(Debug, Default, Clone, Copy)]
121pub struct ComparableHaversine;
122
123// ---- DistanceStrategy impls -----------------------------------------
124//
125// The `SameAs<SphericalFamily>` bounds on both points enforce the
126// spherical-only rule. A caller wiring a Cartesian or Geographic
127// point through here by mistake gets the
128// `#[diagnostic::on_unimplemented]` plate on `geometry_tag::SameAs`
129// (the same plate Pythagoras relies on) pointing them at
130// `WithCs<_, Spherical<…>>` or at the geographic strategies; the
131// extra `#[diagnostic::on_unimplemented]` plate below adds a
132// Haversine-specific note for the most common confusion (geographic
133// vs spherical).
134
135/// Haversine on `f64` spherical points.
136///
137/// Mirrors the `apply(p1, p2)` member of
138/// `boost::geometry::strategy::distance::haversine` at
139/// `strategies/spherical/distance_haversine.hpp:188-199`:
140///
141/// ```text
142/// d = 2 · R · asin(sqrt(h))
143/// where h = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)
144/// ```
145///
146/// # Diagnostics on mis-paired CS
147///
148/// A caller who pairs a Cartesian or Geographic point with
149/// [`Haversine`] hits the `<P::Cs as CoordinateSystem>::Family:
150/// SameAs<SphericalFamily>` bound below and gets the redirect plate
151/// on [`geometry_tag::SameAs`] pointing them at
152/// `WithCs<_, Spherical<…>>` or at the geographic strategies
153/// (Andoyer, Vincenty). See T31 and proposal §3.7.
154#[cfg(feature = "std")]
155impl<P1, P2> DistanceStrategy<P1, P2> for Haversine
156where
157 P1: Point<Scalar = f64>,
158 P2: Point<Scalar = f64>,
159 P1::Cs: HasAngularUnits,
160 P2::Cs: HasAngularUnits,
161 <P1::Cs as CoordinateSystem>::Family: SameAs<SphericalFamily>,
162 <P2::Cs as CoordinateSystem>::Family: SameAs<SphericalFamily>,
163{
164 type Out = f64;
165 type Comparable = ComparableHaversine;
166
167 #[inline]
168 fn distance(&self, a: &P1, b: &P2) -> Self::Out {
169 let h = comparable_haversine_h::<P1, P2>(a, b);
170 // d = 2 · R · asin(sqrt(h)) — mirrors
171 // `distance_haversine.hpp:191-194`.
172 2.0 * self.radius * h.sqrt().asin()
173 }
174
175 #[inline]
176 fn comparable(&self) -> Self::Comparable {
177 ComparableHaversine
178 }
179}
180
181#[cfg(feature = "std")]
182impl<P1, P2> DistanceStrategy<P1, P2> for ComparableHaversine
183where
184 P1: Point<Scalar = f64>,
185 P2: Point<Scalar = f64>,
186 P1::Cs: HasAngularUnits,
187 P2::Cs: HasAngularUnits,
188 <P1::Cs as CoordinateSystem>::Family: SameAs<SphericalFamily>,
189 <P2::Cs as CoordinateSystem>::Family: SameAs<SphericalFamily>,
190{
191 type Out = f64;
192 type Comparable = Self;
193
194 #[inline]
195 fn distance(&self, a: &P1, b: &P2) -> Self::Out {
196 comparable_haversine_h::<P1, P2>(a, b)
197 }
198
199 #[inline]
200 fn comparable(&self) -> Self::Comparable {
201 *self
202 }
203}
204
205// ---- Default Spherical × Spherical = Haversine ----------------------
206
207/// Spherical × Spherical defaults to Haversine.
208///
209/// Mirrors the `services::default_strategy<point_tag, point_tag, P1,
210/// P2, spherical_equatorial_tag, spherical_equatorial_tag>`
211/// specialisation in `strategies/spherical/distance_haversine.hpp`
212/// (around the `services::*` block at lines 222-260).
213impl DefaultDistance<SphericalFamily> for SphericalFamily {
214 type Strategy = Haversine;
215}
216
217// ---- Shared kernel --------------------------------------------------
218
219/// Compute the inner `h` term of the haversine formula:
220///
221/// ```text
222/// h = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlon/2)
223/// ```
224///
225/// Direct port of the body of
226/// `comparable::haversine::apply` at
227/// `strategies/spherical/distance_haversine.hpp:111-119`. Both
228/// inputs are first normalised to radians via [`lonlat_radians`], so
229/// the kernel does not care whether the source CS carries `Degree`
230/// or `Radian` units — that mirrors Boost's implicit
231/// `math::d2r<T>()` multiplication at the entry of each spherical
232/// strategy.
233#[cfg(feature = "std")]
234#[inline]
235fn comparable_haversine_h<P1, P2>(a: &P1, b: &P2) -> f64
236where
237 P1: Point<Scalar = f64>,
238 P2: Point<Scalar = f64>,
239 P1::Cs: HasAngularUnits,
240 P2::Cs: HasAngularUnits,
241{
242 let (lon1, lat1) = lonlat_radians(a);
243 let (lon2, lat2) = lonlat_radians(b);
244
245 let dlat_half = (lat2 - lat1) * 0.5;
246 let dlon_half = (lon2 - lon1) * 0.5;
247
248 let s_lat = dlat_half.sin();
249 let s_lon = dlon_half.sin();
250
251 s_lat * s_lat + lat1.cos() * lat2.cos() * s_lon * s_lon
252}
253
254// ---- Tests ----------------------------------------------------------
255
256#[cfg(all(test, feature = "std"))]
257mod tests {
258 //! Reference values come from
259 //! `geometry/test/strategies/haversine.cpp` (lines 29 and 60-76)
260 //! and `geometry/doc/src/examples/quick_start.cpp:137-148`.
261
262 use super::{ComparableHaversine, Haversine};
263 use crate::distance::DistanceStrategy;
264 use geometry_adapt::{Adapt, WithCs};
265 use geometry_cs::{Degree, Spherical};
266
267 #[inline]
268 fn deg(lon: f64, lat: f64) -> WithCs<Adapt<[f64; 2]>, Spherical<Degree>> {
269 WithCs::new(Adapt([lon, lat]))
270 }
271
272 /// `test/strategies/haversine.cpp:66-67` — Amsterdam → Paris on
273 /// the Boost average earth, expected `467_270.4 m` within `1 m`.
274 #[test]
275 fn amsterdam_paris_average_earth() {
276 let p1 = deg(4.0, 52.0);
277 let p2 = deg(2.0, 48.0);
278 let d = Haversine::EARTH.distance(&p1, &p2);
279 assert!((d - 467_270.4).abs() < 1.0, "got {d} expected ~ 467270.4");
280 }
281
282 /// `test/strategies/haversine.cpp:67-68` — symmetric in arguments.
283 #[test]
284 fn symmetric_in_arguments() {
285 let p1 = deg(4.0, 52.0);
286 let p2 = deg(2.0, 48.0);
287 let ab = Haversine::EARTH.distance(&p1, &p2);
288 let ba = Haversine::EARTH.distance(&p2, &p1);
289 assert!((ab - ba).abs() < 1e-6);
290 }
291
292 /// `test/strategies/haversine.cpp:69` — same arc on a unit
293 /// sphere returns the angular distance (radians).
294 #[test]
295 fn unit_sphere_returns_radians() {
296 let p1 = deg(4.0, 52.0);
297 let p2 = deg(2.0, 48.0);
298 let angle = Haversine::UNIT.distance(&p1, &p2);
299 // metres / earth radius == radians
300 let expected = 467_270.4 / 6_372_795.0;
301 assert!((angle - expected).abs() < 1e-6);
302 }
303
304 /// `test/strategies/haversine.cpp:72-75` — Amsterdam → Barcelona,
305 /// `1_232_906.5 m` within `1 m`.
306 #[test]
307 fn amsterdam_barcelona() {
308 let p1 = deg(4.0, 52.0);
309 let p2 = deg(2.0, 41.0);
310 let d = Haversine::EARTH.distance(&p1, &p2);
311 assert!(
312 (d - 1_232_906.5).abs() < 1.0,
313 "got {d} expected ~ 1232906.5"
314 );
315 }
316
317 /// Comparable form preserves ordering vs. the real distance —
318 /// the whole point of skipping `sqrt` and `asin`.
319 #[test]
320 fn comparable_orders_match_distance() {
321 let o = deg(0.0, 0.0);
322 let near = deg(1.0, 0.0);
323 let far = deg(45.0, 0.0);
324 let h_near = ComparableHaversine.distance(&o, &near);
325 let h_far = ComparableHaversine.distance(&o, &far);
326 assert!(h_near < h_far);
327
328 let d_near = Haversine::EARTH.distance(&o, &near);
329 let d_far = Haversine::EARTH.distance(&o, &far);
330 assert!(d_near < d_far);
331 }
332
333 /// `doc/src/examples/quick_start.cpp:137-148` — "Distance in
334 /// miles: 267.02" using the unit-sphere shortcut multiplied by
335 /// the Earth's radius in miles.
336 #[test]
337 fn quickstart_amsterdam_paris_in_miles() {
338 let ams = deg(4.90, 52.37);
339 // doc says 48.86 (not 48.85)
340 let par = deg(2.35, 48.86);
341 let angle = Haversine::UNIT.distance(&ams, &par);
342 let miles = angle * 3959.0;
343 assert!(
344 (miles - 267.02).abs() < 0.05,
345 "got {miles} expected ~ 267.02"
346 );
347 }
348
349 // KC1.T2 witness: proves this strategy accepts a read-only `Point`
350 // (one that need not implement `PointMut`). If it compiles, the
351 // read-only bound is locked.
352 fn _accepts_readonly_point<P, S>(s: &S, a: &P, b: &P) -> S::Out
353 where
354 P: geometry_trait::Point,
355 S: DistanceStrategy<P, P>,
356 {
357 s.distance(a, b)
358 }
359
360 type GP = WithCs<Adapt<[f64; 2]>, Spherical<Degree>>;
361
362 /// `Haversine::comparable()` yields a `ComparableHaversine` that
363 /// returns the sqrt-free `h` term; taking `asin(sqrt(h)) · 2R` of it
364 /// recovers the full Haversine distance.
365 #[test]
366 fn comparable_yields_the_h_term() {
367 let a = deg(4.0, 52.0);
368 let b = deg(2.0, 48.0);
369 let full = Haversine::UNIT.distance(&a, &b);
370 let h = DistanceStrategy::<GP, GP>::comparable(&Haversine::UNIT).distance(&a, &b);
371 // Reconstruct the angle from the comparable term.
372 let reconstructed = 2.0 * h.sqrt().asin();
373 assert!(
374 (full - reconstructed).abs() < 1e-12,
375 "{full} vs {reconstructed}"
376 );
377 }
378
379 /// `ComparableHaversine::comparable()` returns itself, and its
380 /// `distance` preserves the ordering of the full distance (the whole
381 /// point of the comparable form): a nearer pair has a smaller `h`.
382 #[test]
383 fn comparable_of_comparable_is_itself_and_order_preserving() {
384 let o = deg(0.0, 0.0);
385 let near = deg(1.0, 0.0);
386 let far = deg(10.0, 0.0);
387 let cmp = DistanceStrategy::<GP, GP>::comparable(&ComparableHaversine);
388 assert!(cmp.distance(&o, &near) < cmp.distance(&o, &far));
389 }
390
391 /// The read-only-point witness computes a distance when invoked.
392 #[test]
393 #[allow(
394 clippy::used_underscore_items,
395 reason = "the test exists to run the compile-time witness's body"
396 )]
397 fn readonly_witness_computes_distance() {
398 let d = _accepts_readonly_point(&Haversine::UNIT, °(0.0, 0.0), °(1.0, 0.0));
399 assert!(d > 0.0, "got {d}");
400 }
401}