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//! - [`GeoidGrid::from_egm96_dac`], a loader for the authoritative NGA EGM96
21//!   15-arcminute binary grid (`WW15MGH.DAC`) for decimetre-class datum work;
22//! - [`egm96_undulation`] / [`egm96_grid`], a zero-setup lookup against an
23//!   embedded genuine EGM96 1-degree global grid (a higher-accuracy alternative
24//!   to the coarse built-in);
25//! - [`geoid_undulation`], a zero-setup lookup against a small COARSE built-in
26//!   global grid, plus [`orthometric_height_m`] / [`ellipsoidal_height_m`] height
27//!   conversion helpers.
28//!
29//! ## Choosing a grid
30//!
31//! Three accuracy tiers are available, in increasing fidelity:
32//!
33//! 1. [`geoid_undulation`] - the COARSE 30-degree built-in. It reproduces the
34//!    large-scale character of the geoid (the Indian Ocean low, the North
35//!    Atlantic / New Guinea highs, the polar offsets) and is fine for tests,
36//!    sanity checks, and metre-scale fallback, but it is NOT survey-grade
37//!    (decametre-level error).
38//! 2. [`egm96_undulation`] - an embedded GENUINE EGM96 1-degree global grid,
39//!    decimated from the official NGA 15-arcminute model. Its bilinear lookup
40//!    agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS (95th
41//!    percentile ~0.7 m; up to a few metres over the steepest geoid gradients).
42//!    This is the recommended zero-setup default for metre-class datum work.
43//! 3. [`GeoidGrid::from_egm96_dac`] with the official `WW15MGH.DAC` file (a
44//!    ~2 MB download, not vendored here) - the full 15-arcminute resolution. Its
45//!    bilinear lookup tracks the geoid to roughly decimetre RMS, but the
46//!    worst-case bilinear interpolation error can still exceed 1 m over the
47//!    steepest geoid gradients (see
48//!    <https://geographiclib.sourceforge.io/html/geoid.html> for the egm96-15
49//!    error envelope), so this path supports decimetre-class typical datum work
50//!    rather than guaranteed sub-metre accuracy everywhere. Embedding the full
51//!    grid is impractical (the 15-arcminute grid is ~1 M samples and EGM2008
52//!    1-minute is ~2.3 GB), so the high-resolution path loads the file at
53//!    runtime.
54//!
55//! A caller with any other vendor grid can lower it to [`GeoidGrid::from_text`]
56//! or build a [`GeoidGrid`] via [`GeoidGrid::new`] and call
57//! [`GeoidGrid::undulation_rad`] directly.
58
59use std::sync::OnceLock;
60
61/// Why a geoid grid could not be constructed or parsed.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum GeoidError {
64    /// A grid dimension was zero, or the value count did not equal `n_lat * n_lon`.
65    InvalidDimensions {
66        /// What was expected.
67        expected: usize,
68        /// What was supplied.
69        found: usize,
70    },
71    /// A grid spacing or origin was non-finite or non-positive.
72    InvalidSpacing {
73        /// The offending field.
74        field: &'static str,
75    },
76    /// A grid sample value was non-finite.
77    NonFiniteValue {
78        /// Row-major index of the offending sample.
79        index: usize,
80    },
81    /// The grid text could not be parsed.
82    Parse {
83        /// A human-readable reason.
84        reason: String,
85    },
86}
87
88impl core::fmt::Display for GeoidError {
89    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90        match self {
91            Self::InvalidDimensions { expected, found } => {
92                write!(
93                    f,
94                    "geoid grid expected {expected} samples but found {found}"
95                )
96            }
97            Self::InvalidSpacing { field } => {
98                write!(f, "geoid grid {field} must be finite and positive")
99            }
100            Self::NonFiniteValue { index } => {
101                write!(f, "geoid grid sample {index} is not finite")
102            }
103            Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
104        }
105    }
106}
107
108impl std::error::Error for GeoidError {}
109
110/// A regular latitude/longitude grid of geoid undulation samples (metres) with
111/// bilinear interpolation.
112///
113/// Samples are stored row-major with latitude ascending (outer) and longitude
114/// ascending (inner): `values_m[i * n_lon + j]` is the undulation at latitude
115/// `lat_min_deg + i * dlat_deg` and longitude `lon_min_deg + j * dlon_deg`.
116///
117/// Latitude inputs are clamped to the grid's latitude span. Longitude inputs are
118/// normalized to `[-180, 180)` and then, when the grid spans a full 360 degrees
119/// of longitude, wrapped across the antimeridian; otherwise they are clamped to
120/// the grid's longitude span (so a regional grid does not wrap).
121#[derive(Debug, Clone, PartialEq)]
122pub struct GeoidGrid {
123    lat_min_deg: f64,
124    lon_min_deg: f64,
125    dlat_deg: f64,
126    dlon_deg: f64,
127    n_lat: usize,
128    n_lon: usize,
129    values_m: Vec<f64>,
130}
131
132impl GeoidGrid {
133    /// Build a geoid grid from its origin, spacing, dimensions, and row-major
134    /// samples (metres).
135    ///
136    /// Returns [`GeoidError`] when a dimension is zero, the sample count does not
137    /// equal `n_lat * n_lon`, a spacing/origin is non-finite or a spacing is
138    /// non-positive, or a sample is non-finite.
139    pub fn new(
140        lat_min_deg: f64,
141        lon_min_deg: f64,
142        dlat_deg: f64,
143        dlon_deg: f64,
144        n_lat: usize,
145        n_lon: usize,
146        values_m: Vec<f64>,
147    ) -> Result<Self, GeoidError> {
148        if n_lat == 0 || n_lon == 0 {
149            return Err(GeoidError::InvalidDimensions {
150                expected: 1,
151                found: 0,
152            });
153        }
154        let expected = n_lat * n_lon;
155        if values_m.len() != expected {
156            return Err(GeoidError::InvalidDimensions {
157                expected,
158                found: values_m.len(),
159            });
160        }
161        if !lat_min_deg.is_finite() {
162            return Err(GeoidError::InvalidSpacing { field: "lat_min" });
163        }
164        if !lon_min_deg.is_finite() {
165            return Err(GeoidError::InvalidSpacing { field: "lon_min" });
166        }
167        if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
168            return Err(GeoidError::InvalidSpacing { field: "dlat" });
169        }
170        if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
171            return Err(GeoidError::InvalidSpacing { field: "dlon" });
172        }
173        for (index, value) in values_m.iter().enumerate() {
174            if !value.is_finite() {
175                return Err(GeoidError::NonFiniteValue { index });
176            }
177        }
178        Ok(Self {
179            lat_min_deg,
180            lon_min_deg,
181            dlat_deg,
182            dlon_deg,
183            n_lat,
184            n_lon,
185            values_m,
186        })
187    }
188
189    /// Parse a geoid grid from a simple, documented text format (the data-loading
190    /// hook for full EGM grids).
191    ///
192    /// The format is whitespace-delimited with `#` line comments. The first
193    /// non-comment token sequence is a six-field header:
194    ///
195    /// ```text
196    /// lat_min lon_min dlat dlon n_lat n_lon
197    /// ```
198    ///
199    /// followed by exactly `n_lat * n_lon` undulation samples in metres, in
200    /// row-major order (latitude ascending outer, longitude ascending inner).
201    /// All angles are in degrees. This is deliberately a minimal, line-oriented
202    /// format; a caller converting a vendor grid (EGM `.gri`/`.ndp`, a GeoTIFF,
203    /// etc.) lowers it to this shape or builds a [`GeoidGrid`] via [`new`].
204    ///
205    /// [`new`]: GeoidGrid::new
206    pub fn from_text(text: &str) -> Result<Self, GeoidError> {
207        let mut tokens = text
208            .lines()
209            .map(|line| line.split('#').next().unwrap_or(""))
210            .flat_map(str::split_whitespace);
211
212        let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
213            let token = tokens.next().ok_or_else(|| GeoidError::Parse {
214                reason: format!("missing header field {field}"),
215            })?;
216            token.parse::<f64>().map_err(|_| GeoidError::Parse {
217                reason: format!("header field {field} is not a number: {token:?}"),
218            })
219        };
220
221        let lat_min_deg = next_field("lat_min")?;
222        let lon_min_deg = next_field("lon_min")?;
223        let dlat_deg = next_field("dlat")?;
224        let dlon_deg = next_field("dlon")?;
225        let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
226        let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
227
228        let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
229            reason: "n_lat * n_lon overflows".to_string(),
230        })?;
231        let mut values_m = Vec::with_capacity(expected);
232        for token in tokens {
233            let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
234                reason: format!("sample is not a number: {token:?}"),
235            })?;
236            values_m.push(value);
237        }
238
239        Self::new(
240            lat_min_deg,
241            lon_min_deg,
242            dlat_deg,
243            dlon_deg,
244            n_lat,
245            n_lon,
246            values_m,
247        )
248    }
249
250    /// Parse the authoritative NGA EGM96 15-arcminute binary geoid grid
251    /// (`WW15MGH.DAC`) for decimetre-class datum work.
252    ///
253    /// This is the highest-resolution path in the module. Its bilinear lookup
254    /// tracks the geoid to roughly decimetre RMS, but the worst-case bilinear
255    /// interpolation error can still exceed 1 m over the steepest geoid
256    /// gradients, so it does not guarantee sub-metre accuracy everywhere.
257    ///
258    /// The file is a headerless block of `721 * 1440` big-endian `INTEGER*2`
259    /// samples in centimetres, arranged north-to-south by record (record 1 at
260    /// latitude `+90`, last record at `-90`, in `0.25`-degree steps) and, within
261    /// each record, west-to-east by longitude from `0` to `359.75` degrees in
262    /// `0.25`-degree steps. Each sample is divided by 100 to get metres. The rows
263    /// are flipped to the latitude-ascending storage order of [`GeoidGrid`], so
264    /// the resulting grid is global in longitude and wraps across the
265    /// antimeridian like any other full-span grid.
266    ///
267    /// The file is not vendored in this crate (it is a ~2 MB public-domain NGA
268    /// download); fetch `WW15MGH.DAC` from the NGA EGM96 distribution and pass its
269    /// bytes here. For a zero-setup metre-class default without the download, use
270    /// [`egm96_undulation`] instead.
271    ///
272    /// Returns [`GeoidError::Parse`] if the byte length is not exactly
273    /// `721 * 1440 * 2` bytes.
274    pub fn from_egm96_dac(bytes: &[u8]) -> Result<Self, GeoidError> {
275        let expected = EGM96_DAC_N_LAT * EGM96_DAC_N_LON * 2;
276        if bytes.len() != expected {
277            return Err(GeoidError::Parse {
278                reason: format!(
279                    "EGM96 WW15MGH.DAC must be {expected} bytes ({EGM96_DAC_N_LAT} x {EGM96_DAC_N_LON} big-endian int16), got {}",
280                    bytes.len()
281                ),
282            });
283        }
284        let mut values_m = vec![0.0f64; EGM96_DAC_N_LAT * EGM96_DAC_N_LON];
285        for i in 0..EGM96_DAC_N_LAT {
286            // DAC record 0 is +90 (north); GeoidGrid stores latitude ascending,
287            // so internal row i (latitude -90 + i*0.25) reads DAC record N-1-i.
288            let src_row = EGM96_DAC_N_LAT - 1 - i;
289            for c in 0..EGM96_DAC_N_LON {
290                let off = (src_row * EGM96_DAC_N_LON + c) * 2;
291                let cm = i16::from_be_bytes([bytes[off], bytes[off + 1]]);
292                values_m[i * EGM96_DAC_N_LON + c] = f64::from(cm) / 100.0;
293            }
294        }
295        Self::new(
296            -90.0,
297            0.0,
298            0.25,
299            0.25,
300            EGM96_DAC_N_LAT,
301            EGM96_DAC_N_LON,
302            values_m,
303        )
304    }
305
306    /// Whether the grid spans a full 360 degrees of longitude (and therefore
307    /// wraps across the antimeridian during interpolation).
308    fn is_global_longitude(&self) -> bool {
309        ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
310            || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
311    }
312
313    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
314    /// radians (latitude positive north, longitude positive east).
315    pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
316        self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
317    }
318
319    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
320    /// degrees (latitude positive north, longitude positive east).
321    pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
322        let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
323        let (i0, i1, ty) = self.lat_bracket(lat);
324
325        let (j0, j1, tx) = self.lon_bracket(lon_deg);
326
327        let v00 = self.sample(i0, j0);
328        let v01 = self.sample(i0, j1);
329        let v10 = self.sample(i1, j0);
330        let v11 = self.sample(i1, j1);
331
332        let bottom = v00 + (v01 - v00) * tx;
333        let top = v10 + (v11 - v10) * tx;
334        bottom + (top - bottom) * ty
335    }
336
337    fn lat_max_deg(&self) -> f64 {
338        self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
339    }
340
341    /// Latitude bracketing cell indices and fractional position within the cell.
342    fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
343        if self.n_lat == 1 {
344            return (0, 0, 0.0);
345        }
346        let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
347        let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
348        let i0 = (pos.floor() as usize).min(self.n_lat - 2);
349        (i0, i0 + 1, pos - i0 as f64)
350    }
351
352    /// Longitude bracketing cell indices and fractional position within the cell.
353    /// Wraps across the antimeridian for a global grid; clamps for a regional one.
354    fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
355        if self.n_lon == 1 {
356            return (0, 0, 0.0);
357        }
358        let lon = normalize_longitude_deg(lon_deg);
359        if self.is_global_longitude() {
360            let span = self.n_lon as f64 * self.dlon_deg;
361            let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
362            // Guard the rare case where rounding lands offset exactly on span.
363            if offset >= span {
364                offset -= span;
365            }
366            let pos = offset / self.dlon_deg;
367            let j0 = (pos.floor() as usize) % self.n_lon;
368            let j1 = (j0 + 1) % self.n_lon;
369            (j0, j1, pos - pos.floor())
370        } else {
371            let pos =
372                ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
373            let j0 = (pos.floor() as usize).min(self.n_lon - 2);
374            (j0, j0 + 1, pos - j0 as f64)
375        }
376    }
377
378    fn sample(&self, i: usize, j: usize) -> f64 {
379        self.values_m[i * self.n_lon + j]
380    }
381}
382
383/// Parse a non-negative grid count from a float token.
384fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
385    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
386        return Err(GeoidError::Parse {
387            reason: format!("{field} must be a positive integer, got {value}"),
388        });
389    }
390    Ok(value as usize)
391}
392
393/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
394fn normalize_longitude_deg(lon_deg: f64) -> f64 {
395    let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
396    // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
397    if wrapped >= 180.0 {
398        wrapped - 360.0
399    } else {
400        wrapped
401    }
402}
403
404/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
405/// position in radians, from the COARSE built-in global grid.
406///
407/// Latitude is positive north, longitude positive east, both in radians. See
408/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
409/// load a real model with [`GeoidGrid::from_text`] and call
410/// [`GeoidGrid::undulation_rad`].
411pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
412    builtin_grid().undulation_rad(lat_rad, lon_rad)
413}
414
415/// Orthometric height `H = h - N` (metres above mean sea level) from an
416/// ellipsoidal height and a geodetic position in radians, using the COARSE
417/// 30-degree built-in model's undulation (decametre-level error, NOT
418/// survey-grade). For metre-class conversion use [`egm96_orthometric_height_m`];
419/// for a real model, subtract [`GeoidGrid::undulation_rad`] directly.
420pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
421    ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
422}
423
424/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
425/// orthometric height and a geodetic position in radians, using the COARSE
426/// 30-degree built-in model's undulation (decametre-level error, NOT
427/// survey-grade). For metre-class conversion use [`egm96_ellipsoidal_height_m`];
428/// for a real model, add [`GeoidGrid::undulation_rad`] directly.
429pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
430    orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
431}
432
433/// Orthometric height `H = h - N` (metres above mean sea level) from an
434/// ellipsoidal height and a geodetic position in radians, using the embedded
435/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
436///
437/// This is the recommended zero-setup height converter for metre-class datum
438/// work; the [`orthometric_height_m`] sibling uses the COARSE 30-degree built-in
439/// instead and is only suitable for sanity checks.
440pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
441    ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
442}
443
444/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
445/// orthometric height and a geodetic position in radians, using the embedded
446/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
447///
448/// This is the recommended zero-setup height converter for metre-class datum
449/// work; the [`ellipsoidal_height_m`] sibling uses the COARSE 30-degree built-in
450/// instead and is only suitable for sanity checks.
451pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
452    orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
453}
454
455/// Latitude record count of the NGA EGM96 `WW15MGH.DAC` 15-arcminute grid.
456const EGM96_DAC_N_LAT: usize = 721;
457/// Longitude sample count per record of the NGA EGM96 `WW15MGH.DAC` grid.
458const EGM96_DAC_N_LON: usize = 1440;
459
460/// Latitude row count of the embedded genuine EGM96 1-degree grid.
461const EGM96_1DEG_N_LAT: usize = 181;
462/// Longitude column count of the embedded genuine EGM96 1-degree grid.
463const EGM96_1DEG_N_LON: usize = 360;
464
465// Provenance of the embedded EGM96 1-degree undulation grid
466// (`egm96_geoid_1deg.bin`):
467//
468// Source model: EGM96 (Earth Gravitational Model 1996), the joint NIMA (now
469// NGA) / NASA GSFC / Ohio State University global geopotential model. The geoid
470// undulation grid is a work of the U.S. Government and is in the public domain;
471// NGA distributes it without restriction. See THIRD-PARTY-NOTICES.md.
472//
473// Origin file: the official NGA 15-arcminute binary grid `WW15MGH.DAC`
474// (721 x 1440 big-endian INTEGER*2 centimetres, north-to-south records,
475// longitude 0..359.75 E), obtained from the public OpenSGeo PROJ vdatum mirror
476// (download.osgeo.org/proj/vdatum/egm96_15/). `egm96_geoid_1deg.bin` is that
477// grid decimated to a 1-degree lattice: each sample is the exact `WW15MGH.DAC`
478// value at the corresponding integer-degree node (no resampling or smoothing -
479// 1 degree is an integer multiple of the 0.25-degree source spacing), so every
480// value is a genuine EGM96 undulation, not a fabricated or fitted figure. The
481// packed format is 181 x 360 big-endian INTEGER*2 centimetres in
482// latitude-ascending (-90..+90), longitude-ascending (0..359 E) row-major order.
483// Decimating to 1 degree keeps the embedded data tractable (~127 KB) while its
484// bilinear lookup tracks the full 15-arcminute grid to ~0.4 m RMS.
485
486/// Bytes of the embedded genuine EGM96 1-degree undulation grid (big-endian
487/// int16 centimetres, latitude-ascending, longitude-ascending row-major).
488const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
489
490/// The embedded genuine EGM96 1-degree global geoid, decoded once on first use.
491///
492/// See [`egm96_undulation`] for the recommended scalar entry point and the
493/// module docs for the accuracy tiers.
494pub fn egm96_grid() -> &'static GeoidGrid {
495    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
496    GRID.get_or_init(|| {
497        assert_eq!(
498            EGM96_1DEG_BYTES.len(),
499            EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
500            "embedded EGM96 1-degree grid has the wrong byte length"
501        );
502        let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
503        for (k, value) in values_m.iter_mut().enumerate() {
504            let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
505            *value = f64::from(cm) / 100.0;
506        }
507        GeoidGrid::new(
508            -90.0,
509            0.0,
510            1.0,
511            1.0,
512            EGM96_1DEG_N_LAT,
513            EGM96_1DEG_N_LON,
514            values_m,
515        )
516        .expect("embedded EGM96 1-degree grid is well-formed")
517    })
518}
519
520/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
521/// position in radians, from the embedded GENUINE EGM96 1-degree global grid.
522///
523/// Latitude is positive north, longitude positive east, both in radians. This is
524/// the recommended zero-setup default for metre-class datum work: its bilinear
525/// lookup agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS. For the
526/// full-resolution model load the official `WW15MGH.DAC` via
527/// [`GeoidGrid::from_egm96_dac`]; for the lowest-fidelity legacy fallback use
528/// [`geoid_undulation`].
529pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
530    egm96_grid().undulation_rad(lat_rad, lon_rad)
531}
532
533/// The coarse 30-degree built-in global geoid, built once on first use.
534fn builtin_grid() -> &'static GeoidGrid {
535    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
536    GRID.get_or_init(|| {
537        GeoidGrid::new(
538            -90.0,
539            -180.0,
540            30.0,
541            30.0,
542            BUILTIN_N_LAT,
543            BUILTIN_N_LON,
544            BUILTIN_VALUES_M.to_vec(),
545        )
546        .expect("built-in geoid grid is well-formed")
547    })
548}
549
550const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
551const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
552
553/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
554/// ascending then longitude ascending. The values approximate the large-scale
555/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
556/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
557/// longitude columns coincide on the antimeridian so the global wrap is
558/// continuous.
559#[rustfmt::skip]
560const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
561    // lat = -90 (south pole)
562    -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,
563    // lat = -60
564    -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,
565    // lat = -30
566     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,
567    // lat = 0 (equator)
568    -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,
569    // lat = 30
570      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,
571    // lat = 60
572      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,
573    // lat = 90 (north pole)
574     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,
575];
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn builtin_returns_exact_node_values() {
583        // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
584        assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
585        // (lat 0, lon 90 deg) is the Indian Ocean low node.
586        assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
587        // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
588        assert_eq!(
589            geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
590            60.0
591        );
592    }
593
594    #[test]
595    fn builtin_captures_major_geoid_features_by_sign() {
596        // The Indian Ocean is the global geoid low: undulation is strongly negative.
597        let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
598        assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
599        // The North Atlantic is a geoid high: undulation is positive.
600        let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
601        assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
602    }
603
604    #[test]
605    fn bilinear_midpoint_is_the_corner_average() {
606        let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
607        // Cell-center: equal weight to all four corners -> their mean.
608        let center = grid.undulation_deg(5.0, 5.0);
609        assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
610        // Edge midpoints interpolate along one axis only.
611        assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
612        assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
613        // Corners return the node values exactly.
614        assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
615        assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
616    }
617
618    #[test]
619    fn global_grid_wraps_across_the_antimeridian() {
620        // A global grid whose +180 column equals its -180 column interpolates
621        // continuously across the seam: two points a hair either side of the
622        // antimeridian return nearly the same undulation (no discontinuity).
623        let east = geoid_undulation(0.0, 179.999_f64.to_radians());
624        let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
625        assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
626        // The antimeridian node itself is -10 m on the equator row.
627        assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
628        assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
629        // Exactly +180 and -180 are the same physical meridian -> same value.
630        let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
631        let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
632        assert_eq!(plus, minus);
633        assert_eq!(plus, -10.0);
634    }
635
636    #[test]
637    fn orthometric_height_subtracts_undulation() {
638        let lat = 0.0;
639        let lon = 0.0;
640        let n = geoid_undulation(lat, lon);
641        assert_eq!(n, 17.0);
642        // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
643        assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
644        // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
645        assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
646    }
647
648    #[test]
649    fn egm96_height_converters_use_the_egm96_undulation() {
650        // A known point well away from the coarse-grid agreement; the egm96
651        // converters must subtract/add the genuine EGM96 1-degree undulation, not
652        // the coarse 30-degree built-in.
653        let lat = 37.0_f64.to_radians();
654        let lon = (-122.0_f64).to_radians();
655        let n = egm96_undulation(lat, lon);
656        let h = 250.0;
657        let big_h = egm96_orthometric_height_m(h, lat, lon);
658        assert_eq!(big_h, h - n);
659        assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
660        // The egm96 path differs from the coarse path here (different model).
661        assert_ne!(
662            egm96_orthometric_height_m(h, lat, lon),
663            orthometric_height_m(h, lat, lon)
664        );
665    }
666
667    #[test]
668    fn from_text_round_trips_a_grid() {
669        let text = "\
670# coarse 2x3 regional grid
671# lat_min lon_min dlat dlon n_lat n_lon
67210.0 20.0 5.0 5.0 2 3
673  1.0  2.0  3.0   # lat 10 row
674  4.0  5.0  6.0   # lat 15 row
675";
676        let grid = GeoidGrid::from_text(text).expect("parse grid");
677        assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
678        assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
679        // Cell center of the lower-left cell -> mean of the four corners.
680        let center = grid.undulation_deg(12.5, 22.5);
681        assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
682        // A regional grid clamps rather than wraps outside its longitude span.
683        assert_eq!(
684            grid.undulation_deg(10.0, 0.0),
685            grid.undulation_deg(10.0, 20.0)
686        );
687    }
688
689    #[test]
690    fn from_text_rejects_short_data() {
691        let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
692        assert_eq!(
693            GeoidGrid::from_text(text),
694            Err(GeoidError::InvalidDimensions {
695                expected: 4,
696                found: 3
697            })
698        );
699    }
700
701    #[test]
702    fn new_rejects_bad_inputs() {
703        assert!(matches!(
704            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
705            Err(GeoidError::InvalidDimensions { .. })
706        ));
707        assert!(matches!(
708            GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
709            Err(GeoidError::InvalidSpacing { field: "dlat" })
710        ));
711        assert!(matches!(
712            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
713            Err(GeoidError::NonFiniteValue { index: 1 })
714        ));
715    }
716
717    #[test]
718    fn longitude_normalization_folds_into_half_open_interval() {
719        assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
720        assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
721        assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
722        assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
723    }
724
725    /// The embedded EGM96 1-degree grid returns its genuine node values exactly
726    /// at integer-degree positions (a node query is an exact bilinear hit). The
727    /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
728    /// transcribed from the source grid; see the provenance note in this module.
729    #[test]
730    fn egm96_grid_reproduces_genuine_nodes() {
731        // (lat_deg, lon_deg, expected EGM96 undulation in metres).
732        let nodes: [(f64, f64, f64); 5] = [
733            (0.0, 0.0, 17.16),    // Gulf of Guinea
734            (0.0, 80.0, -102.69), // Indian Ocean low
735            (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
736            (-90.0, 0.0, -29.53), // south pole
737            (90.0, 0.0, 13.61),   // north pole
738        ];
739        for (lat, lon, want) in nodes {
740            let got = egm96_undulation(lat.to_radians(), lon.to_radians());
741            assert!(
742                (got - want).abs() <= 1.0e-9,
743                "egm96 node ({lat},{lon}): got {got}, want {want}"
744            );
745        }
746    }
747
748    /// The embedded EGM96 grid matches the independently published EGM96 geoid
749    /// height at a known checkpoint within the tolerance set by its 1-degree
750    /// resolution, and is far closer to truth than the coarse built-in.
751    ///
752    /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
753    /// `16:46:33N 3:00:34W` (Timbuktu); see
754    /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
755    /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
756    /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
757    /// published value, well inside a 1-degree-resolution tolerance.
758    #[test]
759    fn egm96_grid_matches_published_checkpoint() {
760        let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
761        let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
762        let published = 28.7068;
763
764        let egm96 = egm96_undulation(lat, lon);
765        assert!(
766            (egm96 - published).abs() < 0.5,
767            "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
768        );
769
770        // The genuine 1-degree grid must be strictly closer to the published
771        // value than the decametre-scale 30-degree built-in.
772        let coarse = geoid_undulation(lat, lon);
773        assert!(
774            (egm96 - published).abs() < (coarse - published).abs(),
775            "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
776        );
777    }
778
779    /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
780    /// centimetres, north-to-south records flipped to latitude-ascending storage,
781    /// longitude `0..359.75` E. Validated against an independently built grid of
782    /// the same samples, plus the byte-length guard.
783    #[test]
784    fn from_egm96_dac_decodes_the_nga_layout() {
785        let n_lat = super::EGM96_DAC_N_LAT;
786        let n_lon = super::EGM96_DAC_N_LON;
787        // A deterministic per-(record, column) pattern, well within int16 cm.
788        let cm = |record: usize, col: usize| -> i16 {
789            ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
790        };
791
792        let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
793        for record in 0..n_lat {
794            for col in 0..n_lon {
795                bytes.extend_from_slice(&cm(record, col).to_be_bytes());
796            }
797        }
798        let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
799
800        // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
801        // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
802        let mut values_m = vec![0.0f64; n_lat * n_lon];
803        for i in 0..n_lat {
804            let record = n_lat - 1 - i;
805            for col in 0..n_lon {
806                values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
807            }
808        }
809        let expected =
810            GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
811        assert_eq!(parsed, expected);
812
813        // A wrong byte length is rejected, not silently misread.
814        assert!(matches!(
815            GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
816            Err(GeoidError::Parse { .. })
817        ));
818    }
819}