Skip to main content

sidereon_core/
geoid.rs

1//! Geoid undulation (geoid height) lookup with bilinear interpolation.
2//!
3//! The geoid undulation `N` is the height of the geoid (mean sea level
4//! equipotential surface) above the WGS84 reference ellipsoid, in metres.
5//! GNSS positioning yields the ellipsoidal height `h`; the orthometric height
6//! `H` (height above mean sea level) is
7//!
8//! ```text
9//! H = h - N
10//! ```
11//!
12//! A geoid model is published as a regular latitude/longitude grid of `N`
13//! samples (EGM96, EGM2008, and the national models all ship this way). This
14//! module provides:
15//!
16//! - [`GeoidGrid`], a regular grid of undulation samples with bilinear
17//!   interpolation ([`GeoidGrid::undulation_rad`] / [`GeoidGrid::undulation_deg`]);
18//! - [`GeoidGrid::from_text`], a data-loading hook that parses a simple,
19//!   documented grid text format so a caller can supply a full EGM grid;
20//! - [`geoid_undulation`], a zero-setup lookup against a small COARSE built-in
21//!   global grid, plus [`orthometric_height_m`] / [`ellipsoidal_height_m`] height
22//!   conversion helpers.
23//!
24//! ## Built-in grid vs. loading a real model
25//!
26//! Embedding a full-resolution EGM grid (EGM2008 is a 1-minute, ~2.3 GB grid)
27//! is impractical to vendor into the crate, so the built-in grid is a COARSE
28//! 30-degree global field. It reproduces the large-scale character of the geoid
29//! (the Indian Ocean low, the North Atlantic / New Guinea highs, the polar
30//! offsets) and is suitable for tests, sanity checks, and metre-scale fallback,
31//! but it is NOT survey-grade. Production code should load a real model through
32//! [`GeoidGrid::from_text`] (or build a [`GeoidGrid`] from any parsed source)
33//! and call [`GeoidGrid::undulation_rad`] directly.
34
35use std::sync::OnceLock;
36
37/// Why a geoid grid could not be constructed or parsed.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum GeoidError {
40    /// A grid dimension was zero, or the value count did not equal `n_lat * n_lon`.
41    InvalidDimensions {
42        /// What was expected.
43        expected: usize,
44        /// What was supplied.
45        found: usize,
46    },
47    /// A grid spacing or origin was non-finite or non-positive.
48    InvalidSpacing {
49        /// The offending field.
50        field: &'static str,
51    },
52    /// A grid sample value was non-finite.
53    NonFiniteValue {
54        /// Row-major index of the offending sample.
55        index: usize,
56    },
57    /// The grid text could not be parsed.
58    Parse {
59        /// A human-readable reason.
60        reason: String,
61    },
62}
63
64impl core::fmt::Display for GeoidError {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        match self {
67            Self::InvalidDimensions { expected, found } => {
68                write!(
69                    f,
70                    "geoid grid expected {expected} samples but found {found}"
71                )
72            }
73            Self::InvalidSpacing { field } => {
74                write!(f, "geoid grid {field} must be finite and positive")
75            }
76            Self::NonFiniteValue { index } => {
77                write!(f, "geoid grid sample {index} is not finite")
78            }
79            Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
80        }
81    }
82}
83
84impl std::error::Error for GeoidError {}
85
86/// A regular latitude/longitude grid of geoid undulation samples (metres) with
87/// bilinear interpolation.
88///
89/// Samples are stored row-major with latitude ascending (outer) and longitude
90/// ascending (inner): `values_m[i * n_lon + j]` is the undulation at latitude
91/// `lat_min_deg + i * dlat_deg` and longitude `lon_min_deg + j * dlon_deg`.
92///
93/// Latitude inputs are clamped to the grid's latitude span. Longitude inputs are
94/// normalized to `[-180, 180)` and then, when the grid spans a full 360 degrees
95/// of longitude, wrapped across the antimeridian; otherwise they are clamped to
96/// the grid's longitude span (so a regional grid does not wrap).
97#[derive(Debug, Clone, PartialEq)]
98pub struct GeoidGrid {
99    lat_min_deg: f64,
100    lon_min_deg: f64,
101    dlat_deg: f64,
102    dlon_deg: f64,
103    n_lat: usize,
104    n_lon: usize,
105    values_m: Vec<f64>,
106}
107
108impl GeoidGrid {
109    /// Build a geoid grid from its origin, spacing, dimensions, and row-major
110    /// samples (metres).
111    ///
112    /// Returns [`GeoidError`] when a dimension is zero, the sample count does not
113    /// equal `n_lat * n_lon`, a spacing/origin is non-finite or a spacing is
114    /// non-positive, or a sample is non-finite.
115    pub fn new(
116        lat_min_deg: f64,
117        lon_min_deg: f64,
118        dlat_deg: f64,
119        dlon_deg: f64,
120        n_lat: usize,
121        n_lon: usize,
122        values_m: Vec<f64>,
123    ) -> Result<Self, GeoidError> {
124        if n_lat == 0 || n_lon == 0 {
125            return Err(GeoidError::InvalidDimensions {
126                expected: 1,
127                found: 0,
128            });
129        }
130        let expected = n_lat * n_lon;
131        if values_m.len() != expected {
132            return Err(GeoidError::InvalidDimensions {
133                expected,
134                found: values_m.len(),
135            });
136        }
137        if !lat_min_deg.is_finite() {
138            return Err(GeoidError::InvalidSpacing { field: "lat_min" });
139        }
140        if !lon_min_deg.is_finite() {
141            return Err(GeoidError::InvalidSpacing { field: "lon_min" });
142        }
143        if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
144            return Err(GeoidError::InvalidSpacing { field: "dlat" });
145        }
146        if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
147            return Err(GeoidError::InvalidSpacing { field: "dlon" });
148        }
149        for (index, value) in values_m.iter().enumerate() {
150            if !value.is_finite() {
151                return Err(GeoidError::NonFiniteValue { index });
152            }
153        }
154        Ok(Self {
155            lat_min_deg,
156            lon_min_deg,
157            dlat_deg,
158            dlon_deg,
159            n_lat,
160            n_lon,
161            values_m,
162        })
163    }
164
165    /// Parse a geoid grid from a simple, documented text format (the data-loading
166    /// hook for full EGM grids).
167    ///
168    /// The format is whitespace-delimited with `#` line comments. The first
169    /// non-comment token sequence is a six-field header:
170    ///
171    /// ```text
172    /// lat_min lon_min dlat dlon n_lat n_lon
173    /// ```
174    ///
175    /// followed by exactly `n_lat * n_lon` undulation samples in metres, in
176    /// row-major order (latitude ascending outer, longitude ascending inner).
177    /// All angles are in degrees. This is deliberately a minimal, line-oriented
178    /// format; a caller converting a vendor grid (EGM `.gri`/`.ndp`, a GeoTIFF,
179    /// etc.) lowers it to this shape or builds a [`GeoidGrid`] via [`new`].
180    ///
181    /// [`new`]: GeoidGrid::new
182    pub fn from_text(text: &str) -> Result<Self, GeoidError> {
183        let mut tokens = text
184            .lines()
185            .map(|line| line.split('#').next().unwrap_or(""))
186            .flat_map(str::split_whitespace);
187
188        let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
189            let token = tokens.next().ok_or_else(|| GeoidError::Parse {
190                reason: format!("missing header field {field}"),
191            })?;
192            token.parse::<f64>().map_err(|_| GeoidError::Parse {
193                reason: format!("header field {field} is not a number: {token:?}"),
194            })
195        };
196
197        let lat_min_deg = next_field("lat_min")?;
198        let lon_min_deg = next_field("lon_min")?;
199        let dlat_deg = next_field("dlat")?;
200        let dlon_deg = next_field("dlon")?;
201        let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
202        let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
203
204        let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
205            reason: "n_lat * n_lon overflows".to_string(),
206        })?;
207        let mut values_m = Vec::with_capacity(expected);
208        for token in tokens {
209            let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
210                reason: format!("sample is not a number: {token:?}"),
211            })?;
212            values_m.push(value);
213        }
214
215        Self::new(
216            lat_min_deg,
217            lon_min_deg,
218            dlat_deg,
219            dlon_deg,
220            n_lat,
221            n_lon,
222            values_m,
223        )
224    }
225
226    /// Whether the grid spans a full 360 degrees of longitude (and therefore
227    /// wraps across the antimeridian during interpolation).
228    fn is_global_longitude(&self) -> bool {
229        ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
230            || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
231    }
232
233    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
234    /// radians (latitude positive north, longitude positive east).
235    pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
236        self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
237    }
238
239    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
240    /// degrees (latitude positive north, longitude positive east).
241    pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
242        let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
243        let (i0, i1, ty) = self.lat_bracket(lat);
244
245        let (j0, j1, tx) = self.lon_bracket(lon_deg);
246
247        let v00 = self.sample(i0, j0);
248        let v01 = self.sample(i0, j1);
249        let v10 = self.sample(i1, j0);
250        let v11 = self.sample(i1, j1);
251
252        let bottom = v00 + (v01 - v00) * tx;
253        let top = v10 + (v11 - v10) * tx;
254        bottom + (top - bottom) * ty
255    }
256
257    fn lat_max_deg(&self) -> f64 {
258        self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
259    }
260
261    /// Latitude bracketing cell indices and fractional position within the cell.
262    fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
263        if self.n_lat == 1 {
264            return (0, 0, 0.0);
265        }
266        let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
267        let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
268        let i0 = (pos.floor() as usize).min(self.n_lat - 2);
269        (i0, i0 + 1, pos - i0 as f64)
270    }
271
272    /// Longitude bracketing cell indices and fractional position within the cell.
273    /// Wraps across the antimeridian for a global grid; clamps for a regional one.
274    fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
275        if self.n_lon == 1 {
276            return (0, 0, 0.0);
277        }
278        let lon = normalize_longitude_deg(lon_deg);
279        if self.is_global_longitude() {
280            let span = self.n_lon as f64 * self.dlon_deg;
281            let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
282            // Guard the rare case where rounding lands offset exactly on span.
283            if offset >= span {
284                offset -= span;
285            }
286            let pos = offset / self.dlon_deg;
287            let j0 = (pos.floor() as usize) % self.n_lon;
288            let j1 = (j0 + 1) % self.n_lon;
289            (j0, j1, pos - pos.floor())
290        } else {
291            let pos =
292                ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
293            let j0 = (pos.floor() as usize).min(self.n_lon - 2);
294            (j0, j0 + 1, pos - j0 as f64)
295        }
296    }
297
298    fn sample(&self, i: usize, j: usize) -> f64 {
299        self.values_m[i * self.n_lon + j]
300    }
301}
302
303/// Parse a non-negative grid count from a float token.
304fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
305    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
306        return Err(GeoidError::Parse {
307            reason: format!("{field} must be a positive integer, got {value}"),
308        });
309    }
310    Ok(value as usize)
311}
312
313/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
314fn normalize_longitude_deg(lon_deg: f64) -> f64 {
315    let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
316    // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
317    if wrapped >= 180.0 {
318        wrapped - 360.0
319    } else {
320        wrapped
321    }
322}
323
324/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
325/// position in radians, from the COARSE built-in global grid.
326///
327/// Latitude is positive north, longitude positive east, both in radians. See
328/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
329/// load a real model with [`GeoidGrid::from_text`] and call
330/// [`GeoidGrid::undulation_rad`].
331pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
332    builtin_grid().undulation_rad(lat_rad, lon_rad)
333}
334
335/// Orthometric height `H = h - N` (metres above mean sea level) from an
336/// ellipsoidal height and a geodetic position in radians, using the built-in
337/// grid's undulation. For a real model, subtract
338/// [`GeoidGrid::undulation_rad`] directly.
339pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
340    ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
341}
342
343/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
344/// orthometric height and a geodetic position in radians, using the built-in
345/// grid's undulation. For a real model, add [`GeoidGrid::undulation_rad`]
346/// directly.
347pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
348    orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
349}
350
351/// The coarse 30-degree built-in global geoid, built once on first use.
352fn builtin_grid() -> &'static GeoidGrid {
353    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
354    GRID.get_or_init(|| {
355        GeoidGrid::new(
356            -90.0,
357            -180.0,
358            30.0,
359            30.0,
360            BUILTIN_N_LAT,
361            BUILTIN_N_LON,
362            BUILTIN_VALUES_M.to_vec(),
363        )
364        .expect("built-in geoid grid is well-formed")
365    })
366}
367
368const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
369const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
370
371/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
372/// ascending then longitude ascending. The values approximate the large-scale
373/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
374/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
375/// longitude columns coincide on the antimeridian so the global wrap is
376/// continuous.
377#[rustfmt::skip]
378const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
379    // lat = -90 (south pole)
380    -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0,
381    // lat = -60
382    -15.0, -20.0, -25.0, -10.0,   5.0,  15.0,  20.0,  10.0,   0.0,  -5.0, -10.0, -12.0, -15.0,
383    // lat = -30
384     20.0,  10.0,  -5.0, -25.0, -15.0,   5.0,  25.0,  30.0,  20.0,  35.0,  40.0,  25.0,  20.0,
385    // lat = 0 (equator)
386    -10.0, -20.0, -15.0,  -8.0,  -5.0,   5.0,  17.0,  10.0, -30.0, -60.0,  30.0,  55.0, -10.0,
387    // lat = 30
388      5.0,   0.0, -15.0, -10.0, -40.0,  50.0,  45.0,  20.0, -25.0, -45.0,   0.0,  20.0,   5.0,
389    // lat = 60
390      0.0, -10.0, -20.0, -35.0, -20.0,  60.0,  45.0,  25.0,  10.0,  -5.0, -15.0,  -5.0,   0.0,
391    // lat = 90 (north pole)
392     13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,  13.0,
393];
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn builtin_returns_exact_node_values() {
401        // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
402        assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
403        // (lat 0, lon 90 deg) is the Indian Ocean low node.
404        assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
405        // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
406        assert_eq!(
407            geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
408            60.0
409        );
410    }
411
412    #[test]
413    fn builtin_captures_major_geoid_features_by_sign() {
414        // The Indian Ocean is the global geoid low: undulation is strongly negative.
415        let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
416        assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
417        // The North Atlantic is a geoid high: undulation is positive.
418        let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
419        assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
420    }
421
422    #[test]
423    fn bilinear_midpoint_is_the_corner_average() {
424        let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
425        // Cell-center: equal weight to all four corners -> their mean.
426        let center = grid.undulation_deg(5.0, 5.0);
427        assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
428        // Edge midpoints interpolate along one axis only.
429        assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
430        assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
431        // Corners return the node values exactly.
432        assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
433        assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
434    }
435
436    #[test]
437    fn global_grid_wraps_across_the_antimeridian() {
438        // A global grid whose +180 column equals its -180 column interpolates
439        // continuously across the seam: two points a hair either side of the
440        // antimeridian return nearly the same undulation (no discontinuity).
441        let east = geoid_undulation(0.0, 179.999_f64.to_radians());
442        let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
443        assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
444        // The antimeridian node itself is -10 m on the equator row.
445        assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
446        assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
447        // Exactly +180 and -180 are the same physical meridian -> same value.
448        let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
449        let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
450        assert_eq!(plus, minus);
451        assert_eq!(plus, -10.0);
452    }
453
454    #[test]
455    fn orthometric_height_subtracts_undulation() {
456        let lat = 0.0;
457        let lon = 0.0;
458        let n = geoid_undulation(lat, lon);
459        assert_eq!(n, 17.0);
460        // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
461        assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
462        // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
463        assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
464    }
465
466    #[test]
467    fn from_text_round_trips_a_grid() {
468        let text = "\
469# coarse 2x3 regional grid
470# lat_min lon_min dlat dlon n_lat n_lon
47110.0 20.0 5.0 5.0 2 3
472  1.0  2.0  3.0   # lat 10 row
473  4.0  5.0  6.0   # lat 15 row
474";
475        let grid = GeoidGrid::from_text(text).expect("parse grid");
476        assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
477        assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
478        // Cell center of the lower-left cell -> mean of the four corners.
479        let center = grid.undulation_deg(12.5, 22.5);
480        assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
481        // A regional grid clamps rather than wraps outside its longitude span.
482        assert_eq!(
483            grid.undulation_deg(10.0, 0.0),
484            grid.undulation_deg(10.0, 20.0)
485        );
486    }
487
488    #[test]
489    fn from_text_rejects_short_data() {
490        let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
491        assert_eq!(
492            GeoidGrid::from_text(text),
493            Err(GeoidError::InvalidDimensions {
494                expected: 4,
495                found: 3
496            })
497        );
498    }
499
500    #[test]
501    fn new_rejects_bad_inputs() {
502        assert!(matches!(
503            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
504            Err(GeoidError::InvalidDimensions { .. })
505        ));
506        assert!(matches!(
507            GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
508            Err(GeoidError::InvalidSpacing { field: "dlat" })
509        ));
510        assert!(matches!(
511            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
512            Err(GeoidError::NonFiniteValue { index: 1 })
513        ));
514    }
515
516    #[test]
517    fn longitude_normalization_folds_into_half_open_interval() {
518        assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
519        assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
520        assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
521        assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
522    }
523}