Skip to main content

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, &deg(0.0, 0.0), &deg(1.0, 0.0));
399        assert!(d > 0.0, "got {d}");
400    }
401}