Skip to main content

hpr_core/
geodesic.rs

1//! Geodesics on an ellipsoid: the distance and bearing between two places, and the place a given
2//! distance away along a bearing.
3//!
4//! A geodesic is the shortest path between two points on the ellipsoid's surface. The two
5//! problems are Karney's:
6//!
7//! - **inverse** ([`Ellipsoid::geodesic_inverse`]): given two points, the length `s₁₂` of the
8//!   geodesic between them and its azimuths `α₁` (leaving point 1) and `α₂` (arriving at point 2);
9//! - **direct** ([`Ellipsoid::geodesic_direct`]): given a point, an azimuth `α₁` and a distance
10//!   `s₁₂`, the end point and `α₂`.
11//!
12//! The algorithms are C. F. F. Karney, *Algorithms for geodesics*, J. Geodesy 87 (2013) 43–55,
13//! doi:10.1007/s00190-012-0578-z (arXiv:1109.4448v2). The ellipsoid is mapped onto an auxiliary
14//! sphere, where a geodesic is a great circle, and distance and longitude are corrected by series
15//! in the third flattening `n = f/(2 − f)` to sixth order in the flattening `f` (§2, the direct
16//! problem); the inverse finds `α₁` by Newton's method (§4), from a starting guess (§5). The code
17//! is GeographicLib's, as georust's `geographiclib-rs` ports it (MIT). Karney states that round-off
18//! in both problems stays under 15 nanometers on WGS 84 (§7, page 10), and that for `f` up to
19//! 1/150 the series' truncation is smaller than round-off (page 9): past that the series lose
20//! accuracy, so a flatter ellipsoid is refused ([`GEODESIC_MAX_FLATTENING`]). Karney's published
21//! test set of 500,000 WGS 84 geodesics (doi:10.5281/zenodo.32156) is the check:
22//! `tests/geodtest.rs`, with the measured errors in `docs/physics/geodesy.md`.
23//!
24//! Azimuths are clockwise from north, in radians, in `[−π, π]`; `.rem_euclid(TAU)` gives the
25//! 0 to 2π of a compass. `α₂` is the direction of travel at point 2, so the bearing back to point 1
26//! from there is `α₂ ± π`. Heights play no part: the path lies on the ellipsoid's surface, not at
27//! the points' heights.
28
29use std::f64::consts::{PI, TAU};
30
31use serde::{Deserialize, Serialize};
32
33use geographiclib_rs::{DirectGeodesic, Geodesic as Solver, InverseGeodesic};
34
35use crate::error::CoreError;
36use crate::geodesy::{Ellipsoid, Geodetic};
37
38/// The largest flattening the geodesics accept, 1/150: Karney 2013 (page 9) shows the sixth-order
39/// series' truncation below `f64` round-off up to there. GeographicLib's own error table for the
40/// series grows to 10 µm at `f` = 0.05 and 0.3 m at 0.2. Every planet-like body hpr flies on is
41/// inside: WGS 84's `f` is 1/298.257.
42pub const GEODESIC_MAX_FLATTENING: f64 = 1.0 / 150.0;
43
44/// The geodesic between two points: Karney's inverse problem.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
46#[non_exhaustive]
47pub struct GeodesicInverse {
48    /// The geodesic's length `s₁₂` on the ellipsoid's surface, m.
49    pub distance_m: f64,
50    /// Azimuth `α₁` leaving point 1, rad, clockwise from north, in `[−π, π]`: the bearing from
51    /// point 1 to point 2.
52    pub initial_azimuth_rad: f64,
53    /// Azimuth `α₂` arriving at point 2, rad, clockwise from north, in `[−π, π]`: the direction of
54    /// travel there, not the bearing back to point 1 (that is `α₂ ± π`).
55    pub final_azimuth_rad: f64,
56}
57
58/// The end of a geodesic from a start, an azimuth and a distance: Karney's direct problem.
59///
60/// The end has no height: the geodesic lies on the ellipsoid's surface. [`GeodesicDirect::end`]
61/// makes a position of it at a height the caller chooses.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63#[non_exhaustive]
64pub struct GeodesicDirect {
65    /// The end's geodetic latitude, rad, in `[−π/2, π/2]`.
66    pub latitude_rad: f64,
67    /// The end's longitude, rad, in `[−π, π]`.
68    pub longitude_rad: f64,
69    /// Azimuth `α₂` arriving at the end, rad, clockwise from north, in `[−π, π]`.
70    pub final_azimuth_rad: f64,
71}
72
73impl GeodesicDirect {
74    /// The end as a position at ellipsoidal height `height_m`.
75    ///
76    /// # Errors
77    ///
78    /// As [`Geodetic::new`]: [`CoreError::Domain`] if the height is not finite.
79    pub fn end(&self, height_m: f64) -> Result<Geodetic, CoreError> {
80        Geodetic::new(self.latitude_rad, self.longitude_rad, height_m)
81    }
82}
83
84/// An angle in degrees for the solver. One outside `[−π, π]` is first reduced modulo 2π, so that a
85/// longitude of many turns, which [`Geodetic::new`] accepts, can't overflow to infinity.
86fn degrees(angle_rad: f64) -> f64 {
87    if angle_rad.abs() <= PI {
88        angle_rad.to_degrees()
89    } else {
90        (angle_rad % TAU).to_degrees()
91    }
92}
93
94impl Ellipsoid {
95    fn geodesic_solver(&self) -> Result<Solver, CoreError> {
96        if self.flattening() > GEODESIC_MAX_FLATTENING {
97            return Err(CoreError::Domain {
98                what: "flattening for geodesics (at most 1/150)",
99                value: self.flattening(),
100            });
101        }
102        Ok(Solver::new(self.semi_major_axis_m(), self.flattening()))
103    }
104
105    /// The geodesic from `from` to `to` (Karney 2013 §4, §5): its length and the azimuths at both
106    /// ends. Heights are ignored.
107    ///
108    /// Two coincident points give a zero distance. Where the points are nearly antipodal, or one
109    /// is near a pole, the azimuths are ill-conditioned: a tiny move of a point turns them through
110    /// large angles while the distance hardly changes. And the shortest path is not always
111    /// unique: when `φ₂ = −φ₁` exactly, two geodesics of the same length join the points (unless
112    /// `α₁ = α₂`), the second with `α₁` and `α₂` swapped (GeographicLib's `GeodSolve` manual,
113    /// *Multiple solutions*); either may be returned.
114    ///
115    /// ```
116    /// use hpr_core::geodesy::{Ellipsoid, Geodetic};
117    ///
118    /// // Two points a degree of longitude apart on the equator.
119    /// let a = Geodetic::from_degrees(0.0, 0.0, 0.0)?;
120    /// let b = Geodetic::from_degrees(0.0, 1.0, 0.0)?;
121    /// let g = Ellipsoid::WGS84.geodesic_inverse(a, b)?;
122    /// // An arc of the equator: a·(π/180).
123    /// assert!((g.distance_m - 111_319.490_793_273_57).abs() < 1e-8);
124    /// assert!((g.initial_azimuth_rad.to_degrees() - 90.0).abs() < 1e-12);
125    /// # Ok::<(), hpr_core::CoreError>(())
126    /// ```
127    ///
128    /// # Errors
129    ///
130    /// [`CoreError::Domain`] if the ellipsoid's flattening is past [`GEODESIC_MAX_FLATTENING`],
131    /// or either point fails [`Geodetic::validated`]'s checks.
132    pub fn geodesic_inverse(
133        &self,
134        from: Geodetic,
135        to: Geodetic,
136    ) -> Result<GeodesicInverse, CoreError> {
137        let solver = self.geodesic_solver()?;
138        let (from, to) = (from.validated()?, to.validated()?);
139        let (distance_m, azi1_deg, azi2_deg, _arc_deg) = solver.inverse(
140            from.latitude_rad.to_degrees(),
141            degrees(from.longitude_rad),
142            to.latitude_rad.to_degrees(),
143            degrees(to.longitude_rad),
144        );
145        Ok(GeodesicInverse {
146            distance_m,
147            initial_azimuth_rad: azi1_deg.to_radians(),
148            final_azimuth_rad: azi2_deg.to_radians(),
149        })
150    }
151
152    /// The point `distance_m` along the geodesic leaving `from` at azimuth `azimuth_rad`
153    /// (clockwise from north), with the azimuth there (Karney 2013 §2). `from`'s height is
154    /// ignored. A negative distance runs backwards along the same geodesic. Karney's accuracy is
155    /// shown up to half a meridian (20,004 km); a distance of many circuits also carries its own
156    /// rounding, one ulp of `distance_m` (at least 15 nm past 2²⁶ m, 67,109 km).
157    ///
158    /// ```
159    /// use hpr_core::geodesy::{Ellipsoid, Geodetic};
160    ///
161    /// // A degree of longitude east along the equator, as above.
162    /// let a = Geodetic::from_degrees(0.0, 0.0, 0.0)?;
163    /// let d = Ellipsoid::WGS84.geodesic_direct(a, 90f64.to_radians(), 111_319.490_793_273_57)?;
164    /// assert!((d.longitude_rad.to_degrees() - 1.0).abs() < 1e-12);
165    /// assert!(d.latitude_rad.abs() < 1e-15);
166    /// # Ok::<(), hpr_core::CoreError>(())
167    /// ```
168    ///
169    /// # Errors
170    ///
171    /// [`CoreError::Domain`] if the ellipsoid's flattening is past [`GEODESIC_MAX_FLATTENING`],
172    /// `from` fails [`Geodetic::validated`]'s checks, or the azimuth or the distance is not
173    /// finite.
174    pub fn geodesic_direct(
175        &self,
176        from: Geodetic,
177        azimuth_rad: f64,
178        distance_m: f64,
179    ) -> Result<GeodesicDirect, CoreError> {
180        let solver = self.geodesic_solver()?;
181        let from = from.validated()?;
182        if !azimuth_rad.is_finite() {
183            return Err(CoreError::Domain {
184                what: "geodesic azimuth (rad)",
185                value: azimuth_rad,
186            });
187        }
188        if !distance_m.is_finite() {
189            return Err(CoreError::Domain {
190                what: "geodesic distance (m)",
191                value: distance_m,
192            });
193        }
194        let (lat2_deg, lon2_deg, azi2_deg) = solver.direct(
195            from.latitude_rad.to_degrees(),
196            degrees(from.longitude_rad),
197            degrees(azimuth_rad),
198            distance_m,
199        );
200        if !(lat2_deg.is_finite() && lon2_deg.is_finite() && azi2_deg.is_finite()) {
201            // Not seen for any finite input; kept so that a NaN can't leave as a place.
202            return Err(CoreError::Domain {
203                what: "geodesic distance (m)",
204                value: distance_m,
205            });
206        }
207        Ok(GeodesicDirect {
208            latitude_rad: lat2_deg.to_radians(),
209            longitude_rad: lon2_deg.to_radians(),
210            final_azimuth_rad: azi2_deg.to_radians(),
211        })
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use std::f64::consts::{FRAC_PI_2, PI};
218
219    use proptest::prelude::*;
220
221    use super::*;
222
223    const WGS84: Ellipsoid = Ellipsoid::WGS84;
224
225    fn point(latitude_deg: f64, longitude_deg: f64) -> Geodetic {
226        Geodetic::from_degrees(latitude_deg, longitude_deg, 0.0).expect("a valid point")
227    }
228
229    #[test]
230    fn on_a_sphere_the_distance_is_the_great_circle_arc() {
231        // The central angle by the haversine formula, and the initial bearing by the spherical
232        // azimuth formula `tan α₁ = sin Δλ cos φ₂ / (cos φ₁ sin φ₂ − sin φ₁ cos φ₂ cos Δλ)`.
233        let sphere = Ellipsoid::new(6_371_000.0, f64::INFINITY).expect("a sphere");
234        for (from, to) in [
235            (point(10.0, 20.0), point(-35.0, 140.0)),
236            (point(60.0, -5.0), point(61.0, 2.5)),
237            (point(-80.0, 0.0), point(0.0, -170.0)),
238        ] {
239            let (p1, p2, dl) = (
240                from.latitude_rad,
241                to.latitude_rad,
242                to.longitude_rad - from.longitude_rad,
243            );
244            let h =
245                ((p2 - p1) / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2);
246            let arc = 2.0 * h.sqrt().asin();
247            let azimuth =
248                (dl.sin() * p2.cos()).atan2(p1.cos() * p2.sin() - p1.sin() * p2.cos() * dl.cos());
249            let g = sphere.geodesic_inverse(from, to).expect("a geodesic");
250            assert!((g.distance_m - 6_371_000.0 * arc).abs() < 1e-8, "{g:?}");
251            assert!((g.initial_azimuth_rad - azimuth).abs() < 1e-14, "{g:?}");
252        }
253    }
254
255    /// The worked example in `docs/physics/geodesy.md`: a landing 1.2 km from the pad.
256    #[test]
257    fn the_guides_worked_example() {
258        let pad = point(32.990_4, -106.975_0);
259        let landing = point(33.000_0, -106.968_0);
260        let g = WGS84.geodesic_inverse(pad, landing).expect("a geodesic");
261        // The page's digits: 1,249.614 m, 31.567° out, 31.571° on arrival.
262        assert!((g.distance_m - 1_249.614).abs() < 5e-4, "{g:?}");
263        assert!(
264            (g.initial_azimuth_rad.to_degrees() - 31.567).abs() < 5e-4,
265            "{g:?}"
266        );
267        assert!(
268            (g.final_azimuth_rad.to_degrees() - 31.571).abs() < 5e-4,
269            "{g:?}"
270        );
271        // A flat map with the ellipsoid's radii of curvature at the mean latitude, the meridian's
272        // `M = a(1 − e²)/w³` and the prime vertical's `N = a/w` (`w = √(1 − e² sin²φ)`), gives
273        // the same distance to under a millimeter at this range.
274        let mean = 0.5 * (pad.latitude_rad + landing.latitude_rad);
275        let (a, e2) = (WGS84.semi_major_axis_m(), WGS84.eccentricity_squared());
276        let w = (1.0 - e2 * mean.sin().powi(2)).sqrt();
277        let north = a * (1.0 - e2) / w.powi(3) * (landing.latitude_rad - pad.latitude_rad);
278        let east = a / w * mean.cos() * (landing.longitude_rad - pad.longitude_rad);
279        assert!((north.hypot(east) - g.distance_m).abs() < 1e-3);
280        // 2 km at 60°: 32.999415° N, 106.956466° W, heading 60.010°.
281        let d = WGS84
282            .geodesic_direct(pad, 60f64.to_radians(), 2_000.0)
283            .expect("finite");
284        assert!(
285            (d.latitude_rad.to_degrees() - 32.999_415).abs() < 5e-7,
286            "{d:?}"
287        );
288        assert!(
289            (d.longitude_rad.to_degrees() + 106.956_466).abs() < 5e-7,
290            "{d:?}"
291        );
292        assert!(
293            (d.final_azimuth_rad.to_degrees() - 60.010).abs() < 5e-4,
294            "{d:?}"
295        );
296    }
297
298    #[test]
299    fn coincident_points_are_zero_apart() {
300        let p = point(32.99, -106.97);
301        assert_eq!(
302            WGS84.geodesic_inverse(p, p).expect("a geodesic").distance_m,
303            0.0
304        );
305    }
306
307    #[test]
308    fn a_meridian_runs_due_north_at_both_ends() {
309        let g = WGS84
310            .geodesic_inverse(point(0.0, 10.0), point(45.0, 10.0))
311            .expect("a geodesic");
312        assert_eq!(g.initial_azimuth_rad, 0.0);
313        assert_eq!(g.final_azimuth_rad, 0.0);
314        let d = WGS84
315            .geodesic_direct(point(0.0, 10.0), 0.0, g.distance_m)
316            .expect("finite");
317        assert!((d.latitude_rad.to_degrees() - 45.0).abs() < 1e-13);
318    }
319
320    #[test]
321    fn heights_are_ignored() {
322        let low = point(40.0, -100.0);
323        let high = Geodetic::from_degrees(40.0, -100.0, 3_000.0).expect("a valid point");
324        let to = point(41.0, -99.0);
325        assert_eq!(
326            WGS84.geodesic_inverse(low, to).expect("a geodesic"),
327            WGS84.geodesic_inverse(high, to).expect("a geodesic")
328        );
329        let d = WGS84.geodesic_direct(high, 1.0, 5_000.0).expect("finite");
330        assert_eq!(d.end(0.0).expect("a position").height_m, 0.0);
331        assert_eq!(d, WGS84.geodesic_direct(low, 1.0, 5_000.0).expect("finite"));
332    }
333
334    #[test]
335    fn a_negative_distance_runs_backwards() {
336        let from = point(20.0, 30.0);
337        let ahead = WGS84
338            .geodesic_direct(from, 0.7, -250_000.0)
339            .expect("finite");
340        let behind = WGS84
341            .geodesic_direct(from, 0.7 - PI, 250_000.0)
342            .expect("finite");
343        assert!((ahead.latitude_rad - behind.latitude_rad).abs() < 1e-14);
344        assert!((ahead.longitude_rad - behind.longitude_rad).abs() < 1e-14);
345    }
346
347    #[test]
348    fn refuses_flattenings_past_one_in_150() {
349        let a = point(10.0, 20.0);
350        let b = point(-35.0, 140.0);
351        let edge = Ellipsoid::from_flattening(6.4e6, GEODESIC_MAX_FLATTENING).expect("valid");
352        assert!(edge.geodesic_inverse(a, b).is_ok());
353        for f in [GEODESIC_MAX_FLATTENING.next_up(), 0.2] {
354            let past = Ellipsoid::from_flattening(6.4e6, f).expect("a valid ellipsoid");
355            for result in [
356                past.geodesic_inverse(a, b).map(|_| ()),
357                past.geodesic_direct(a, 1.0, 1e6).map(|_| ()),
358            ] {
359                match result {
360                    Err(CoreError::Domain { what, value }) => {
361                        assert_eq!(what, "flattening for geodesics (at most 1/150)");
362                        assert_eq!(value, f);
363                    }
364                    other => panic!("{other:?}"),
365                }
366            }
367        }
368    }
369
370    #[test]
371    fn refuses_points_built_out_of_range() {
372        let bad = Geodetic {
373            latitude_rad: 2.0,
374            longitude_rad: 0.0,
375            height_m: 0.0,
376        };
377        let good = point(0.0, 0.0);
378        for result in [
379            WGS84.geodesic_inverse(bad, good).map(|_| ()),
380            WGS84.geodesic_inverse(good, bad).map(|_| ()),
381            WGS84.geodesic_direct(bad, 1.0, 1.0).map(|_| ()),
382        ] {
383            match result {
384                Err(CoreError::Domain { what, value }) => {
385                    assert_eq!(what, "geodetic latitude (rad)");
386                    assert_eq!(value, 2.0);
387                }
388                other => panic!("{other:?}"),
389            }
390        }
391    }
392
393    #[test]
394    fn a_longitude_of_many_turns_is_reduced() {
395        // 1e307 rad is a valid `Geodetic` longitude whose degrees overflow `f64`.
396        let far = Geodetic::new(0.5, 1e307, 0.0).expect("a valid point");
397        let g = WGS84
398            .geodesic_inverse(far, point(0.0, 0.0))
399            .expect("a geodesic");
400        assert!(g.distance_m.is_finite() && g.initial_azimuth_rad.is_finite());
401        let d = WGS84.geodesic_direct(far, 1e300, 1_000.0).expect("finite");
402        assert!(d.longitude_rad.is_finite() && d.final_azimuth_rad.is_finite());
403        // Within a turn the reduction changes nothing: 3π/2 east is π/2 west.
404        let east = Geodetic::new(0.0, 1.5 * PI, 0.0).expect("a valid point");
405        let west = Geodetic::new(0.0, -0.5 * PI, 0.0).expect("a valid point");
406        let to = point(10.0, 0.0);
407        let (ge, gw) = (
408            WGS84.geodesic_inverse(east, to).expect("a geodesic"),
409            WGS84.geodesic_inverse(west, to).expect("a geodesic"),
410        );
411        assert!((ge.distance_m - gw.distance_m).abs() < 1e-8);
412    }
413
414    #[test]
415    fn results_round_trip_through_serde() {
416        let g = WGS84
417            .geodesic_inverse(point(1.0, 2.0), point(3.0, 4.0))
418            .expect("a geodesic");
419        let text = serde_json::to_string(&g).expect("serializes");
420        assert_eq!(
421            serde_json::from_str::<GeodesicInverse>(&text).expect("reads"),
422            g
423        );
424        let d = WGS84
425            .geodesic_direct(point(1.0, 2.0), 0.3, 5e5)
426            .expect("finite");
427        let text = serde_json::to_string(&d).expect("serializes");
428        assert_eq!(
429            serde_json::from_str::<GeodesicDirect>(&text).expect("reads"),
430            d
431        );
432    }
433
434    #[test]
435    fn direct_refuses_non_finite_inputs() {
436        let from = point(0.0, 0.0);
437        for (azimuth, distance, what) in [
438            (f64::NAN, 1.0, "geodesic azimuth (rad)"),
439            (f64::INFINITY, 1.0, "geodesic azimuth (rad)"),
440            (0.0, f64::NAN, "geodesic distance (m)"),
441            (0.0, f64::NEG_INFINITY, "geodesic distance (m)"),
442        ] {
443            match WGS84.geodesic_direct(from, azimuth, distance) {
444                Err(CoreError::Domain { what: w, .. }) => assert_eq!(w, what),
445                other => panic!("{other:?}"),
446            }
447        }
448    }
449
450    proptest! {
451        #![proptest_config(ProptestConfig::with_cases(256))]
452
453        /// The inverse's bearing and distance lead back to the second point, and the azimuths and
454        /// the end's longitude stay in `[−π, π]`.
455        #[test]
456        fn inverse_then_direct_lands_on_the_second_point(
457            lat1 in -FRAC_PI_2..=FRAC_PI_2,
458            lon1 in -10.0..10.0f64,
459            lat2 in -FRAC_PI_2..=FRAC_PI_2,
460            lon2 in -10.0..10.0f64,
461        ) {
462            let from = Geodetic::new(lat1, lon1, 0.0).expect("a valid point");
463            let to = Geodetic::new(lat2, lon2, 0.0).expect("a valid point");
464            let g = WGS84.geodesic_inverse(from, to).expect("a geodesic");
465            // No geodesic is longer than half a meridian, pole to pole: 20,003,931.4586 m.
466            prop_assert!(g.distance_m >= 0.0 && g.distance_m <= 20_003_931.46);
467            prop_assert!(g.initial_azimuth_rad.abs() <= PI && g.final_azimuth_rad.abs() <= PI);
468            let d = WGS84
469                .geodesic_direct(from, g.initial_azimuth_rad, g.distance_m)
470                .expect("finite");
471            prop_assert!(d.longitude_rad.abs() <= PI && d.final_azimuth_rad.abs() <= PI);
472            let miss = WGS84.ecef_from_geodetic(d.end(0.0).expect("a position")) - WGS84.ecef_from_geodetic(to);
473            prop_assert!(miss.length() < 1e-7, "missed by {} m", miss.length());
474        }
475    }
476}