Skip to main content

hpr_core/
geodesy.rs

1//! Reference ellipsoids, geodetic and Earth-centered Earth-fixed (ECEF) coordinates.
2//!
3//! Formulas and sources are in `docs/physics/geodesy.md`; frame conventions in
4//! `docs/physics/frames.md`.
5//!
6//! - Geodetic to ECEF: NGA.STND.0036_1.0.0_WGS84 (2014), eqs. 4-14 and 4-15.
7//! - ECEF to geodetic: C. F. F. Karney, *Geodesics on an ellipsoid of revolution*,
8//!   arXiv:1102.1215v1 (2011), appendix B, eqs. B1 to B5: Vermeille's closed form, extended by
9//!   Karney to be valid everywhere except in the equatorial plane within `a e²` (about 43 km) of
10//!   the Earth's center.
11
12use glam::{DMat3, DVec3};
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoreError;
16
17/// A reference ellipsoid of revolution, oblate or spherical.
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19#[serde(try_from = "EllipsoidData", into = "EllipsoidData")]
20pub struct Ellipsoid {
21    semi_major_axis_m: f64,
22    flattening: f64,
23}
24
25#[derive(Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27struct EllipsoidData {
28    semi_major_axis_m: f64,
29    /// The flattening itself rather than its inverse, which is infinite for a sphere and has no
30    /// JSON representation.
31    flattening: f64,
32}
33
34impl TryFrom<EllipsoidData> for Ellipsoid {
35    type Error = CoreError;
36
37    fn try_from(data: EllipsoidData) -> Result<Self, CoreError> {
38        Ellipsoid::from_flattening(data.semi_major_axis_m, data.flattening)
39    }
40}
41
42impl From<Ellipsoid> for EllipsoidData {
43    fn from(ellipsoid: Ellipsoid) -> Self {
44        EllipsoidData {
45            semi_major_axis_m: ellipsoid.semi_major_axis_m,
46            flattening: ellipsoid.flattening,
47        }
48    }
49}
50
51impl Ellipsoid {
52    /// The WGS 84 ellipsoid: `a = 6378137.0 m`, `1/f = 298.257223563` (NGA.STND.0036_1.0.0_WGS84,
53    /// Table 3.1).
54    pub const WGS84: Self = Self {
55        semi_major_axis_m: 6_378_137.0,
56        flattening: 1.0 / 298.257_223_563,
57    };
58
59    /// An ellipsoid from its semi-major axis `a` and inverse flattening `1/f`. An infinite `1/f`
60    /// is a sphere.
61    ///
62    /// # Errors
63    ///
64    /// [`CoreError::Domain`] unless `a` is finite and positive and `1/f` is greater than one
65    /// (or `+∞`).
66    pub fn new(semi_major_axis_m: f64, inverse_flattening: f64) -> Result<Self, CoreError> {
67        if inverse_flattening.is_nan() || inverse_flattening <= 1.0 {
68            return Err(CoreError::Domain {
69                what: "inverse flattening",
70                value: inverse_flattening,
71            });
72        }
73        Self::from_flattening(semi_major_axis_m, 1.0 / inverse_flattening)
74    }
75
76    /// An ellipsoid from its semi-major axis `a` and flattening `f` (zero for a sphere).
77    ///
78    /// # Errors
79    ///
80    /// [`CoreError::Domain`] unless `a` is finite and positive and `0 ≤ f < 1`.
81    pub fn from_flattening(semi_major_axis_m: f64, flattening: f64) -> Result<Self, CoreError> {
82        if !(semi_major_axis_m.is_finite() && semi_major_axis_m > 0.0) {
83            return Err(CoreError::Domain {
84                what: "semi-major axis (m)",
85                value: semi_major_axis_m,
86            });
87        }
88        if !(0.0..1.0).contains(&flattening) {
89            return Err(CoreError::Domain {
90                what: "flattening",
91                value: flattening,
92            });
93        }
94        Ok(Self {
95            semi_major_axis_m,
96            flattening,
97        })
98    }
99
100    /// Semi-major (equatorial) axis `a`, m.
101    #[must_use]
102    pub fn semi_major_axis_m(&self) -> f64 {
103        self.semi_major_axis_m
104    }
105
106    /// Flattening `f = (a − b)/a`.
107    #[must_use]
108    pub fn flattening(&self) -> f64 {
109        self.flattening
110    }
111
112    /// Inverse flattening `1/f` (`+∞` for a sphere).
113    #[must_use]
114    pub fn inverse_flattening(&self) -> f64 {
115        1.0 / self.flattening
116    }
117
118    /// Semi-minor (polar) axis `b = a(1 − f)`, m.
119    #[must_use]
120    pub fn semi_minor_axis_m(&self) -> f64 {
121        self.semi_major_axis_m * (1.0 - self.flattening)
122    }
123
124    /// First eccentricity squared `e² = f(2 − f) = (a² − b²)/a²`.
125    #[must_use]
126    pub fn eccentricity_squared(&self) -> f64 {
127        self.flattening * (2.0 - self.flattening)
128    }
129
130    /// Linear eccentricity `E = √(a² − b²) = a e`, m.
131    #[must_use]
132    pub fn linear_eccentricity_m(&self) -> f64 {
133        self.semi_major_axis_m * self.eccentricity_squared().sqrt()
134    }
135
136    /// Radius of curvature in the prime vertical, `N(φ) = a / √(1 − e² sin²φ)`, m (eq. 4-15).
137    #[must_use]
138    pub fn prime_vertical_radius_m(&self, latitude_rad: f64) -> f64 {
139        let s = latitude_rad.sin();
140        self.semi_major_axis_m / (1.0 - self.eccentricity_squared() * s * s).sqrt()
141    }
142
143    /// ECEF position of a geodetic point (NGA.STND.0036 eq. 4-14):
144    ///
145    /// ```text
146    /// X = (N + h) cos φ cos λ,   Y = (N + h) cos φ sin λ,   Z = ((b²/a²) N + h) sin φ
147    /// ```
148    #[must_use]
149    pub fn ecef_from_geodetic(&self, point: Geodetic) -> DVec3 {
150        let (sin_lat, cos_lat) = point.latitude_rad.sin_cos();
151        let (sin_lon, cos_lon) = point.longitude_rad.sin_cos();
152        let n = self.prime_vertical_radius_m(point.latitude_rad);
153        let horizontal = (n + point.height_m) * cos_lat;
154        DVec3::new(
155            horizontal * cos_lon,
156            horizontal * sin_lon,
157            (n * (1.0 - self.eccentricity_squared()) + point.height_m) * sin_lat,
158        )
159    }
160
161    /// Geodetic position of an ECEF point (Karney 2011, appendix B). With `R = √(X² + Y²)`,
162    /// `x = R/a` and `y = √(1 − e²) Z/a`, the largest real root `κ` of
163    ///
164    /// ```text
165    /// κ⁴ + 2e²κ³ − (x² + y² − e⁴)κ² − 2e²y²κ − e⁴y² = 0                        (B1)
166    /// ```
167    ///
168    /// gives `φ = ph(R/(κ + e²) + iZ/κ)` (B2) and `h = (1 − (1 − e²)/κ) √(D² + Z²)` with
169    /// `D = κR/(κ + e²)` (B3). `κ` comes from the resolvent cubic and the factorization (B4)–(B5)
170    /// in the round-off-avoiding form the paper gives. `λ = ph(X + iY)`, which is 0 on the axis.
171    ///
172    /// # Errors
173    ///
174    /// [`CoreError::Domain`] if the position is not finite, or lies in the degenerate set where
175    /// the closed form needs limiting forms: the equatorial plane within `a e²` of the center
176    /// (about 42.7 km for WGS 84), deep inside the Earth.
177    pub fn geodetic_from_ecef(&self, position_ecef_m: DVec3) -> Result<Geodetic, CoreError> {
178        if let Some(value) = first_non_finite(position_ecef_m) {
179            return Err(CoreError::Domain {
180                what: "ECEF position component (m)",
181                value,
182            });
183        }
184        let a = self.semi_major_axis_m;
185        let e2 = self.eccentricity_squared();
186        let e4 = e2 * e2;
187        let big_r = position_ecef_m.x.hypot(position_ecef_m.y);
188        let big_z = position_ecef_m.z;
189        let longitude_rad = position_ecef_m.y.atan2(position_ecef_m.x);
190
191        let x = big_r / a;
192        let y = (1.0 - e2).sqrt() * big_z / a;
193        let (x2, y2) = (x * x, y * y);
194        let r = (x2 + y2 - e4) / 6.0;
195        let s = e4 * x2 * y2 / 4.0;
196        let r3 = r * r * r;
197        let d = s * (s + 2.0 * r3);
198        let u = if d >= 0.0 {
199            // The square root takes the sign of S + r³ to avoid cancellation; the cube root is
200            // real.
201            let t3 = s + r3;
202            let t = (t3 + d.sqrt().copysign(t3)).cbrt();
203            if t == 0.0 { 0.0 } else { r + t + r * r / t }
204        } else {
205            let psi = (-d).sqrt().atan2(-s - r3);
206            r * (1.0 + 2.0 * (psi / 3.0).cos())
207        };
208        let v = (u * u + e4 * y2).sqrt();
209        // v + u, computed without cancellation when u < 0.
210        let v_plus_u = if u < 0.0 { e4 * y2 / (v - u) } else { u + v };
211        let w = (v_plus_u - y2) * e2 / (2.0 * v);
212        let kappa = v_plus_u / ((v_plus_u + w * w).sqrt() + w);
213
214        let latitude_rad = (big_z / kappa).atan2(big_r / (kappa + e2));
215        let big_d = kappa * big_r / (kappa + e2);
216        let height_m = (1.0 - (1.0 - e2) / kappa) * big_d.hypot(big_z);
217        if !(kappa > 0.0 && latitude_rad.is_finite() && height_m.is_finite()) {
218            return Err(CoreError::Domain {
219                what: "distance from the Earth's center (m)",
220                value: position_ecef_m.length(),
221            });
222        }
223        Geodetic::new(latitude_rad, longitude_rad, height_m)
224    }
225}
226
227/// The first component of `v` that is NaN or infinite, for error reports.
228pub(crate) fn first_non_finite(v: DVec3) -> Option<f64> {
229    v.to_array().into_iter().find(|c| !c.is_finite())
230}
231
232/// A geodetic position on an ellipsoid.
233///
234/// The fields are public for convenience; [`Geodetic::new`], deserialization and every
235/// constructor that takes a site ([`crate::frames::LaunchFrame::new`], [`crate::earth::Earth::new`])
236/// check them.
237#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
238#[serde(try_from = "GeodeticData", into = "GeodeticData")]
239pub struct Geodetic {
240    /// Geodetic latitude `φ`, rad, in `[−π/2, π/2]`, positive north.
241    pub latitude_rad: f64,
242    /// Longitude `λ`, rad, positive east of the prime meridian.
243    pub longitude_rad: f64,
244    /// Height `h` above the ellipsoid along its normal, m. This is not height above mean sea
245    /// level: the two differ by the geoid undulation, up to about ±100 m.
246    pub height_m: f64,
247}
248
249#[derive(Serialize, Deserialize)]
250#[serde(deny_unknown_fields)]
251struct GeodeticData {
252    latitude_rad: f64,
253    longitude_rad: f64,
254    height_m: f64,
255}
256
257impl TryFrom<GeodeticData> for Geodetic {
258    type Error = CoreError;
259
260    fn try_from(data: GeodeticData) -> Result<Self, CoreError> {
261        Geodetic::new(data.latitude_rad, data.longitude_rad, data.height_m)
262    }
263}
264
265impl From<Geodetic> for GeodeticData {
266    fn from(point: Geodetic) -> Self {
267        GeodeticData {
268            latitude_rad: point.latitude_rad,
269            longitude_rad: point.longitude_rad,
270            height_m: point.height_m,
271        }
272    }
273}
274
275impl Geodetic {
276    /// Checks a position built from the public fields: the same checks as [`Geodetic::new`].
277    ///
278    /// # Errors
279    ///
280    /// As [`Geodetic::new`].
281    pub fn validated(self) -> Result<Self, CoreError> {
282        Self::new(self.latitude_rad, self.longitude_rad, self.height_m)
283    }
284
285    /// A position from latitude and longitude in radians and ellipsoidal height in meters.
286    ///
287    /// # Errors
288    ///
289    /// [`CoreError::Domain`] if the latitude is outside `[−π/2, π/2]` or any value is not finite.
290    pub fn new(latitude_rad: f64, longitude_rad: f64, height_m: f64) -> Result<Self, CoreError> {
291        if latitude_rad.is_nan() || latitude_rad.abs() > std::f64::consts::FRAC_PI_2 {
292            return Err(CoreError::Domain {
293                what: "geodetic latitude (rad)",
294                value: latitude_rad,
295            });
296        }
297        if !longitude_rad.is_finite() {
298            return Err(CoreError::Domain {
299                what: "longitude (rad)",
300                value: longitude_rad,
301            });
302        }
303        if !height_m.is_finite() {
304            return Err(CoreError::Domain {
305                what: "ellipsoidal height (m)",
306                value: height_m,
307            });
308        }
309        Ok(Self {
310            latitude_rad,
311            longitude_rad,
312            height_m,
313        })
314    }
315
316    /// A position from latitude and longitude in degrees and ellipsoidal height in meters.
317    ///
318    /// # Errors
319    ///
320    /// As [`Geodetic::new`].
321    pub fn from_degrees(
322        latitude_deg: f64,
323        longitude_deg: f64,
324        height_m: f64,
325    ) -> Result<Self, CoreError> {
326        Self::new(
327            latitude_deg.to_radians(),
328            longitude_deg.to_radians(),
329            height_m,
330        )
331    }
332}
333
334/// The rotation whose columns are the East, North and Up unit vectors at `point`, resolved in
335/// ECEF. It maps ENU components to ECEF components; its transpose maps back.
336///
337/// ```text
338/// ê = (−sin λ, cos λ, 0)
339/// n̂ = (−sin φ cos λ, −sin φ sin λ, cos φ)
340/// û = (cos φ cos λ, cos φ sin λ, sin φ)
341/// ```
342#[must_use]
343pub fn ecef_from_enu_rotation(point: Geodetic) -> DMat3 {
344    let (sin_lat, cos_lat) = point.latitude_rad.sin_cos();
345    let (sin_lon, cos_lon) = point.longitude_rad.sin_cos();
346    DMat3::from_cols(
347        DVec3::new(-sin_lon, cos_lon, 0.0),
348        DVec3::new(-sin_lat * cos_lon, -sin_lat * sin_lon, cos_lat),
349        DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat),
350    )
351}
352
353#[cfg(test)]
354mod tests {
355    use std::f64::consts::FRAC_PI_2;
356
357    use proptest::prelude::*;
358
359    use super::*;
360
361    const WGS84: Ellipsoid = Ellipsoid::WGS84;
362
363    #[test]
364    fn wgs84_derived_geometry_matches_table_3_5() {
365        // NGA.STND.0036_1.0.0_WGS84, Table 3.5, to the printed digits.
366        assert!((WGS84.semi_minor_axis_m() - 6_356_752.314_2).abs() < 5e-5);
367        assert!((WGS84.eccentricity_squared() - 6.694_379_990_141e-3).abs() < 5e-16);
368        assert!((WGS84.linear_eccentricity_m() - 5.218_540_084_233_9e5).abs() < 5e-9);
369        assert!((WGS84.flattening() - 3.352_810_664_747_5e-3).abs() < 5e-17);
370    }
371
372    #[test]
373    fn rejects_invalid_ellipsoids_and_points() {
374        assert!(Ellipsoid::new(0.0, 298.0).is_err());
375        assert!(Ellipsoid::new(6.4e6, 1.0).is_err());
376        assert!(Ellipsoid::new(6.4e6, f64::NAN).is_err());
377        assert!(Ellipsoid::new(6.4e6, f64::INFINITY).is_ok());
378        assert!(Geodetic::new(FRAC_PI_2 + 1e-9, 0.0, 0.0).is_err());
379        assert!(Geodetic::new(0.0, f64::INFINITY, 0.0).is_err());
380        assert!(Geodetic::from_degrees(0.0, 0.0, f64::NAN).is_err());
381        assert!(
382            WGS84
383                .geodetic_from_ecef(DVec3::new(f64::NAN, 0.0, 0.0))
384                .is_err()
385        );
386        // The degenerate set: the equatorial plane within a e² of the center.
387        assert!(
388            WGS84
389                .geodetic_from_ecef(DVec3::new(1.0e4, 0.0, 0.0))
390                .is_err()
391        );
392        assert!(WGS84.geodetic_from_ecef(DVec3::ZERO).is_err());
393    }
394
395    #[test]
396    fn closed_form_points_on_the_axes() {
397        let a = WGS84.semi_major_axis_m();
398        let b = WGS84.semi_minor_axis_m();
399        let equator = WGS84.ecef_from_geodetic(Geodetic::from_degrees(0.0, 90.0, 250.0).unwrap());
400        assert!((equator - DVec3::new(0.0, a + 250.0, 0.0)).length() < 1e-9);
401        let pole = WGS84.ecef_from_geodetic(Geodetic::from_degrees(-90.0, 0.0, 1000.0).unwrap());
402        assert!((pole - DVec3::new(0.0, 0.0, -(b + 1000.0))).length() < 1e-9);
403
404        let g = WGS84
405            .geodetic_from_ecef(DVec3::new(0.0, 0.0, b + 3.0e5))
406            .unwrap();
407        assert_eq!(g.latitude_rad, FRAC_PI_2);
408        assert!((g.height_m - 3.0e5).abs() < 1e-8);
409        let g = WGS84
410            .geodetic_from_ecef(DVec3::new(-(a - 1.0e3), 0.0, 0.0))
411            .unwrap();
412        assert_eq!(g.latitude_rad, 0.0);
413        assert!((g.longitude_rad.abs() - std::f64::consts::PI).abs() < 1e-15);
414        assert!((g.height_m + 1.0e3).abs() < 1e-8);
415    }
416
417    #[test]
418    fn a_sphere_inverts_to_spherical_coordinates() {
419        let sphere = Ellipsoid::new(6.371e6, f64::INFINITY).unwrap();
420        let p = DVec3::new(3.0e6, 4.0e6, 5.0e6);
421        let g = sphere.geodetic_from_ecef(p).unwrap();
422        assert!((g.height_m - (p.length() - 6.371e6)).abs() < 1e-8);
423        assert!((g.latitude_rad - (5.0e6f64).atan2(5.0e6)).abs() < 1e-15);
424    }
425
426    #[test]
427    fn serde_round_trips_and_rechecks() {
428        for ellipsoid in [WGS84, Ellipsoid::new(6.371e6, f64::INFINITY).unwrap()] {
429            let json = serde_json::to_string(&ellipsoid).unwrap();
430            assert_eq!(
431                serde_json::from_str::<Ellipsoid>(&json).unwrap(),
432                ellipsoid,
433                "{json}"
434            );
435        }
436        assert!(
437            serde_json::from_str::<Ellipsoid>(r#"{"semi_major_axis_m": 6.4e6, "flattening": 1.0}"#)
438                .is_err()
439        );
440        let point = Geodetic::from_degrees(-33.9, 18.6, 300.0).unwrap();
441        let json = serde_json::to_string(&point).unwrap();
442        assert_eq!(serde_json::from_str::<Geodetic>(&json).unwrap(), point);
443        let degrees_in_radian_fields =
444            r#"{"latitude_rad": 32.99, "longitude_rad": -106.97, "height_m": 1400.0}"#;
445        assert!(serde_json::from_str::<Geodetic>(degrees_in_radian_fields).is_err());
446        let unchecked = Geodetic {
447            latitude_rad: f64::NAN,
448            ..point
449        };
450        assert!(unchecked.validated().is_err());
451        assert_eq!(point.validated(), Ok(point));
452    }
453
454    #[test]
455    fn errors_report_the_offending_component() {
456        let err = WGS84
457            .geodetic_from_ecef(DVec3::new(f64::NAN, 1.0, 2.0))
458            .unwrap_err();
459        assert!(matches!(err, CoreError::Domain { value, .. } if value.is_nan()));
460    }
461
462    #[test]
463    fn enu_rotation_is_proper_and_up_is_the_ellipsoid_normal() {
464        let point = Geodetic::from_degrees(37.2, -115.8, 1300.0).unwrap();
465        let r = ecef_from_enu_rotation(point);
466        assert!((r.transpose() * r).abs_diff_eq(DMat3::IDENTITY, 1e-15));
467        assert!((r.determinant() - 1.0).abs() < 1e-15);
468        // The outward normal of x²/a² + y²/a² + z²/b² = 1 at the foot of the point.
469        let foot = WGS84.ecef_from_geodetic(Geodetic {
470            height_m: 0.0,
471            ..point
472        });
473        let a2 = WGS84.semi_major_axis_m().powi(2);
474        let b2 = WGS84.semi_minor_axis_m().powi(2);
475        let normal = DVec3::new(foot.x / a2, foot.y / a2, foot.z / b2).normalize();
476        assert!((r.z_axis - normal).length() < 1e-15);
477    }
478
479    proptest! {
480        /// Geodetic → ECEF → geodetic, from 10 km below the ellipsoid to 1000 km above it.
481        #[test]
482        fn geodetic_round_trips_through_ecef(
483            lat in -FRAC_PI_2..=FRAC_PI_2,
484            lon in -std::f64::consts::PI..std::f64::consts::PI,
485            h in -1.0e4..1.0e6f64,
486        ) {
487            let point = Geodetic::new(lat, lon, h).unwrap();
488            let ecef = WGS84.ecef_from_geodetic(point);
489            let back = WGS84.geodetic_from_ecef(ecef).unwrap();
490            prop_assert!((back.latitude_rad - lat).abs() < 1e-14, "lat {} vs {}", back.latitude_rad, lat);
491            prop_assert!((back.height_m - h).abs() < 2e-8, "h {} vs {}", back.height_m, h);
492            // Longitude is undefined on the axis; compare positions instead.
493            prop_assert!((WGS84.ecef_from_geodetic(back) - ecef).length() < 2e-8);
494            if lat.abs() < FRAC_PI_2 - 1e-9 {
495                let dlon = (back.longitude_rad - lon + std::f64::consts::PI)
496                    .rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI;
497                prop_assert!(dlon.abs() < 1e-14 / lat.cos().max(1e-6), "lon {} vs {}", back.longitude_rad, lon);
498            }
499        }
500
501        /// ECEF → geodetic → ECEF anywhere from 100 km below the surface to 40,000 km out.
502        #[test]
503        fn ecef_round_trips_through_geodetic(
504            direction in prop::array::uniform3(-1.0..1.0f64),
505            radius in 6.25e6..4.6e7f64,
506        ) {
507            let d = DVec3::from_array(direction);
508            prop_assume!(d.length() > 1e-3);
509            let p = d.normalize() * radius;
510            let g = WGS84.geodetic_from_ecef(p).unwrap();
511            let back = WGS84.ecef_from_geodetic(g);
512            prop_assert!((back - p).length() < 1e-7 * (radius / 6.4e6), "{back} vs {p}");
513        }
514    }
515}