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    /// Batch bilinear undulation lookup for geodetic positions in radians.
320    ///
321    /// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
322    /// longitude positive east. Output element `i` is exactly the scalar
323    /// [`undulation_rad`](Self::undulation_rad) result for input element `i`.
324    pub fn undulations_rad(&self, points_rad: &[(f64, f64)]) -> Vec<f64> {
325        points_rad
326            .iter()
327            .map(|&(lat_rad, lon_rad)| self.undulation_rad(lat_rad, lon_rad))
328            .collect()
329    }
330
331    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
332    /// degrees (latitude positive north, longitude positive east).
333    pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
334        let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
335        let (i0, i1, ty) = self.lat_bracket(lat);
336
337        let (j0, j1, tx) = self.lon_bracket(lon_deg);
338
339        let v00 = self.sample(i0, j0);
340        let v01 = self.sample(i0, j1);
341        let v10 = self.sample(i1, j0);
342        let v11 = self.sample(i1, j1);
343
344        let bottom = v00 + (v01 - v00) * tx;
345        let top = v10 + (v11 - v10) * tx;
346        bottom + (top - bottom) * ty
347    }
348
349    /// Batch bilinear undulation lookup for geodetic positions in degrees.
350    ///
351    /// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
352    /// longitude positive east. Output element `i` is exactly the scalar
353    /// [`undulation_deg`](Self::undulation_deg) result for input element `i`.
354    pub fn undulations_deg(&self, points_deg: &[(f64, f64)]) -> Vec<f64> {
355        points_deg
356            .iter()
357            .map(|&(lat_deg, lon_deg)| self.undulation_deg(lat_deg, lon_deg))
358            .collect()
359    }
360
361    /// Orthometric height `H = h - N` (metres above mean sea level) from an
362    /// ellipsoidal height and a geodetic position in radians, using this grid's
363    /// undulation.
364    pub fn orthometric_height_rad(
365        &self,
366        ellipsoidal_height_m: f64,
367        lat_rad: f64,
368        lon_rad: f64,
369    ) -> f64 {
370        ellipsoidal_height_m - self.undulation_rad(lat_rad, lon_rad)
371    }
372
373    /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
374    /// orthometric height and a geodetic position in radians, using this grid's
375    /// undulation.
376    pub fn ellipsoidal_height_rad(
377        &self,
378        orthometric_height_m: f64,
379        lat_rad: f64,
380        lon_rad: f64,
381    ) -> f64 {
382        orthometric_height_m + self.undulation_rad(lat_rad, lon_rad)
383    }
384
385    /// Orthometric height `H = h - N` (metres above mean sea level) from an
386    /// ellipsoidal height and a geodetic position in degrees, using this grid's
387    /// undulation.
388    pub fn orthometric_height_deg(
389        &self,
390        ellipsoidal_height_m: f64,
391        lat_deg: f64,
392        lon_deg: f64,
393    ) -> f64 {
394        ellipsoidal_height_m - self.undulation_deg(lat_deg, lon_deg)
395    }
396
397    /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
398    /// orthometric height and a geodetic position in degrees, using this grid's
399    /// undulation.
400    pub fn ellipsoidal_height_deg(
401        &self,
402        orthometric_height_m: f64,
403        lat_deg: f64,
404        lon_deg: f64,
405    ) -> f64 {
406        orthometric_height_m + self.undulation_deg(lat_deg, lon_deg)
407    }
408
409    fn lat_max_deg(&self) -> f64 {
410        self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
411    }
412
413    /// Latitude bracketing cell indices and fractional position within the cell.
414    fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
415        if self.n_lat == 1 {
416            return (0, 0, 0.0);
417        }
418        let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
419        let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
420        let i0 = (pos.floor() as usize).min(self.n_lat - 2);
421        (i0, i0 + 1, pos - i0 as f64)
422    }
423
424    /// Longitude bracketing cell indices and fractional position within the cell.
425    /// Wraps across the antimeridian for a global grid; clamps for a regional one.
426    fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
427        if self.n_lon == 1 {
428            return (0, 0, 0.0);
429        }
430        let lon = normalize_longitude_deg(lon_deg);
431        if self.is_global_longitude() {
432            let span = self.n_lon as f64 * self.dlon_deg;
433            let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
434            // Guard the rare case where rounding lands offset exactly on span.
435            if offset >= span {
436                offset -= span;
437            }
438            let pos = offset / self.dlon_deg;
439            let j0 = (pos.floor() as usize) % self.n_lon;
440            let j1 = (j0 + 1) % self.n_lon;
441            (j0, j1, pos - pos.floor())
442        } else {
443            let pos =
444                ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
445            let j0 = (pos.floor() as usize).min(self.n_lon - 2);
446            (j0, j0 + 1, pos - j0 as f64)
447        }
448    }
449
450    fn sample(&self, i: usize, j: usize) -> f64 {
451        self.values_m[i * self.n_lon + j]
452    }
453}
454
455/// Parse a non-negative grid count from a float token.
456fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
457    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
458        return Err(GeoidError::Parse {
459            reason: format!("{field} must be a positive integer, got {value}"),
460        });
461    }
462    Ok(value as usize)
463}
464
465/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
466fn normalize_longitude_deg(lon_deg: f64) -> f64 {
467    let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
468    // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
469    if wrapped >= 180.0 {
470        wrapped - 360.0
471    } else {
472        wrapped
473    }
474}
475
476/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
477/// position in radians, from the COARSE built-in global grid.
478///
479/// Latitude is positive north, longitude positive east, both in radians. See
480/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
481/// load a real model with [`GeoidGrid::from_text`] and call
482/// [`GeoidGrid::undulation_rad`].
483pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
484    builtin_grid().undulation_rad(lat_rad, lon_rad)
485}
486
487/// Batch geoid undulation lookup against the COARSE built-in global grid.
488///
489/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
490/// longitude positive east. Output element `i` is exactly the scalar
491/// [`geoid_undulation`] result for input element `i`.
492pub fn geoid_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
493    builtin_grid().undulations_rad(points_rad)
494}
495
496/// Batch geoid undulation lookup against the COARSE built-in global grid.
497///
498/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
499/// longitude positive east.
500pub fn geoid_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
501    builtin_grid().undulations_deg(points_deg)
502}
503
504/// Orthometric height `H = h - N` (metres above mean sea level) from an
505/// ellipsoidal height and a geodetic position in radians, using the COARSE
506/// 30-degree built-in model's undulation (decametre-level error, NOT
507/// survey-grade). For metre-class conversion use [`egm96_orthometric_height_m`];
508/// for a real model, subtract [`GeoidGrid::undulation_rad`] directly.
509pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
510    ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
511}
512
513/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
514/// orthometric height and a geodetic position in radians, using the COARSE
515/// 30-degree built-in model's undulation (decametre-level error, NOT
516/// survey-grade). For metre-class conversion use [`egm96_ellipsoidal_height_m`];
517/// for a real model, add [`GeoidGrid::undulation_rad`] directly.
518pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
519    orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
520}
521
522/// Orthometric height `H = h - N` (metres above mean sea level) from an
523/// ellipsoidal height and a geodetic position in radians, using the embedded
524/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
525///
526/// This is the recommended zero-setup height converter for metre-class datum
527/// work; the [`orthometric_height_m`] sibling uses the COARSE 30-degree built-in
528/// instead and is only suitable for sanity checks.
529pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
530    ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
531}
532
533/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
534/// orthometric height and a geodetic position in radians, using the embedded
535/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
536///
537/// This is the recommended zero-setup height converter for metre-class datum
538/// work; the [`ellipsoidal_height_m`] sibling uses the COARSE 30-degree built-in
539/// instead and is only suitable for sanity checks.
540pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
541    orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
542}
543
544/// Latitude record count of the NGA EGM96 `WW15MGH.DAC` 15-arcminute grid.
545const EGM96_DAC_N_LAT: usize = 721;
546/// Longitude sample count per record of the NGA EGM96 `WW15MGH.DAC` grid.
547const EGM96_DAC_N_LON: usize = 1440;
548
549/// Latitude row count of the embedded genuine EGM96 1-degree grid.
550const EGM96_1DEG_N_LAT: usize = 181;
551/// Longitude column count of the embedded genuine EGM96 1-degree grid.
552const EGM96_1DEG_N_LON: usize = 360;
553
554// Provenance of the embedded EGM96 1-degree undulation grid
555// (`egm96_geoid_1deg.bin`):
556//
557// Source model: EGM96 (Earth Gravitational Model 1996), the joint NIMA (now
558// NGA) / NASA GSFC / Ohio State University global geopotential model. The geoid
559// undulation grid is a work of the U.S. Government and is in the public domain;
560// NGA distributes it without restriction. See THIRD-PARTY-NOTICES.md.
561//
562// Origin file: the official NGA 15-arcminute binary grid `WW15MGH.DAC`
563// (721 x 1440 big-endian INTEGER*2 centimetres, north-to-south records,
564// longitude 0..359.75 E), obtained from the public OpenSGeo PROJ vdatum mirror
565// (download.osgeo.org/proj/vdatum/egm96_15/). `egm96_geoid_1deg.bin` is that
566// grid decimated to a 1-degree lattice: each sample is the exact `WW15MGH.DAC`
567// value at the corresponding integer-degree node (no resampling or smoothing -
568// 1 degree is an integer multiple of the 0.25-degree source spacing), so every
569// value is a genuine EGM96 undulation, not a fabricated or fitted figure. The
570// packed format is 181 x 360 big-endian INTEGER*2 centimetres in
571// latitude-ascending (-90..+90), longitude-ascending (0..359 E) row-major order.
572// Decimating to 1 degree keeps the embedded data tractable (~127 KB) while its
573// bilinear lookup tracks the full 15-arcminute grid to ~0.4 m RMS.
574
575/// Bytes of the embedded genuine EGM96 1-degree undulation grid (big-endian
576/// int16 centimetres, latitude-ascending, longitude-ascending row-major).
577const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
578
579/// The embedded genuine EGM96 1-degree global geoid, decoded once on first use.
580///
581/// See [`egm96_undulation`] for the recommended scalar entry point and the
582/// module docs for the accuracy tiers.
583pub fn egm96_grid() -> &'static GeoidGrid {
584    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
585    GRID.get_or_init(|| {
586        assert_eq!(
587            EGM96_1DEG_BYTES.len(),
588            EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
589            "embedded EGM96 1-degree grid has the wrong byte length"
590        );
591        let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
592        for (k, value) in values_m.iter_mut().enumerate() {
593            let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
594            *value = f64::from(cm) / 100.0;
595        }
596        GeoidGrid::new(
597            -90.0,
598            0.0,
599            1.0,
600            1.0,
601            EGM96_1DEG_N_LAT,
602            EGM96_1DEG_N_LON,
603            values_m,
604        )
605        .expect("embedded EGM96 1-degree grid is well-formed")
606    })
607}
608
609/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
610/// position in radians, from the embedded GENUINE EGM96 1-degree global grid.
611///
612/// Latitude is positive north, longitude positive east, both in radians. This is
613/// the recommended zero-setup default for metre-class datum work: its bilinear
614/// lookup agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS. For the
615/// full-resolution model load the official `WW15MGH.DAC` via
616/// [`GeoidGrid::from_egm96_dac`]; for the lowest-fidelity legacy fallback use
617/// [`geoid_undulation`].
618pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
619    egm96_grid().undulation_rad(lat_rad, lon_rad)
620}
621
622/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
623/// global grid.
624///
625/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
626/// longitude positive east. Output element `i` is exactly the scalar
627/// [`egm96_undulation`] result for input element `i`.
628pub fn egm96_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
629    egm96_grid().undulations_rad(points_rad)
630}
631
632/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
633/// global grid.
634///
635/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
636/// longitude positive east.
637pub fn egm96_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
638    egm96_grid().undulations_deg(points_deg)
639}
640
641/// The coarse 30-degree built-in global geoid, built once on first use.
642fn builtin_grid() -> &'static GeoidGrid {
643    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
644    GRID.get_or_init(|| {
645        GeoidGrid::new(
646            -90.0,
647            -180.0,
648            30.0,
649            30.0,
650            BUILTIN_N_LAT,
651            BUILTIN_N_LON,
652            BUILTIN_VALUES_M.to_vec(),
653        )
654        .expect("built-in geoid grid is well-formed")
655    })
656}
657
658const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
659const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
660
661/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
662/// ascending then longitude ascending. The values approximate the large-scale
663/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
664/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
665/// longitude columns coincide on the antimeridian so the global wrap is
666/// continuous.
667#[rustfmt::skip]
668const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
669    // lat = -90 (south pole)
670    -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,
671    // lat = -60
672    -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,
673    // lat = -30
674     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,
675    // lat = 0 (equator)
676    -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,
677    // lat = 30
678      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,
679    // lat = 60
680      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,
681    // lat = 90 (north pole)
682     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,
683];
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[derive(Clone, Copy)]
690    struct ProjGeoidFixture {
691        lat_deg: f64,
692        lon_deg: f64,
693        undulation_m: f64,
694    }
695
696    // PROJ oracle provenance for the 15-arcminute EGM96 fixture below:
697    //
698    // Tool: PROJ 9.8.1 (`cct`, Rel. 9.8.1, April 10th, 2026).
699    // Grid: `us_nga_egm96_15.tif`, fetched with
700    // `projsync --target-dir /tmp/sidereon-proj-egm96 --file us_nga_egm96_15.tif`.
701    // Grid SHA-256:
702    // db493027562c9b004d7220fa881f5603adada4e1c5029b933fa7de4547b0e78d.
703    // Command:
704    // `PROJ_DATA=/tmp/sidereon-proj-egm96 cct -d 12 +proj=pipeline +step +inv
705    //  +proj=vgridshift +grids=us_nga_egm96_15.tif +multiplier=1`.
706    //
707    // `cct` returns orthometric height for an ellipsoidal-height input. With
708    // input height 0, the geoid undulation is `-output_z`.
709    const PROJ_EGM96_FIXTURES: &[ProjGeoidFixture] = &[
710        ProjGeoidFixture {
711            lat_deg: 0.000000,
712            lon_deg: 0.000000,
713            undulation_m: 17.161579132080,
714        },
715        ProjGeoidFixture {
716            lat_deg: 0.000000,
717            lon_deg: 80.000000,
718            undulation_m: -102.687904357910,
719        },
720        ProjGeoidFixture {
721            lat_deg: 60.000000,
722            lon_deg: -30.000000,
723            undulation_m: 63.799266815186,
724        },
725        ProjGeoidFixture {
726            lat_deg: 45.625000,
727            lon_deg: 12.375000,
728            undulation_m: 44.181870460510,
729        },
730        ProjGeoidFixture {
731            lat_deg: 0.125000,
732            lon_deg: 179.875000,
733            undulation_m: 21.099070549011,
734        },
735        ProjGeoidFixture {
736            lat_deg: 0.125000,
737            lon_deg: -179.875000,
738            undulation_m: 20.864660263062,
739        },
740        ProjGeoidFixture {
741            lat_deg: -10.500000,
742            lon_deg: 179.990000,
743            undulation_m: 38.607539978027,
744        },
745        ProjGeoidFixture {
746            lat_deg: -10.500000,
747            lon_deg: -179.990000,
748            undulation_m: 38.540365447998,
749        },
750        ProjGeoidFixture {
751            lat_deg: 89.875000,
752            lon_deg: 45.000000,
753            undulation_m: 13.639517307281,
754        },
755        ProjGeoidFixture {
756            lat_deg: -89.875000,
757            lon_deg: 123.625000,
758            undulation_m: -29.676423549652,
759        },
760        ProjGeoidFixture {
761            lat_deg: 37.774900,
762            lon_deg: -122.419400,
763            undulation_m: -32.242452185586,
764        },
765    ];
766
767    // Real EGM96 15-arcminute node values, rounded to the centimetre grid the
768    // NGA `WW15MGH.DAC` format stores. The sparse test grid writes only these
769    // nodes into an otherwise-zero DAC-sized byte buffer; each oracle point
770    // above falls in a cell whose four corners are present here. This avoids
771    // committing the full 2 MB grid while still checking node registration,
772    // antimeridian wrap, pole-row handling, and bilinear cell selection against
773    // PROJ-derived values. The largest measured PROJ-vs-DAC-centimetre
774    // difference in these fixtures is 0.0032 m.
775    const SPARSE_EGM96_DAC_NODES_CM: &[(f64, f64, i16)] = &[
776        (-90.00, 123.50, -2953),
777        (-90.00, 123.75, -2953),
778        (-89.75, 123.50, -2982),
779        (-89.75, 123.75, -2982),
780        (-10.50, 179.75, 3919),
781        (-10.50, 180.00, 3858),
782        (-10.50, 180.25, 3751),
783        (-10.25, 179.75, 3733),
784        (-10.25, 180.00, 3697),
785        (-10.25, 180.25, 3611),
786        (0.00, 0.00, 1716),
787        (0.00, 0.25, 1708),
788        (0.00, 80.00, -10269),
789        (0.00, 80.25, -10255),
790        (0.00, 179.75, 2138),
791        (0.00, 180.00, 2115),
792        (0.00, 180.25, 2095),
793        (0.25, 0.00, 1719),
794        (0.25, 0.25, 1711),
795        (0.25, 80.00, -10286),
796        (0.25, 80.25, -10276),
797        (0.25, 179.75, 2109),
798        (0.25, 180.00, 2078),
799        (0.25, 180.25, 2058),
800        (37.75, 237.50, -3237),
801        (37.75, 237.75, -3204),
802        (38.00, 237.50, -3211),
803        (38.00, 237.75, -3200),
804        (45.50, 12.25, 4398),
805        (45.50, 12.50, 4355),
806        (45.75, 12.25, 4498),
807        (45.75, 12.50, 4421),
808        (60.00, 330.00, 6380),
809        (60.00, 330.25, 6400),
810        (60.25, 330.00, 6365),
811        (60.25, 330.25, 6388),
812        (89.75, 45.00, 1367),
813        (89.75, 45.25, 1367),
814        (90.00, 45.00, 1361),
815        (90.00, 45.25, 1361),
816    ];
817
818    fn sparse_egm96_dac_bytes() -> Vec<u8> {
819        let mut bytes = vec![0u8; super::EGM96_DAC_N_LAT * super::EGM96_DAC_N_LON * 2];
820        for &(lat_deg, lon_east_deg, cm) in SPARSE_EGM96_DAC_NODES_CM {
821            let record = ((90.0 - lat_deg) / 0.25).round() as usize;
822            let col = (lon_east_deg.rem_euclid(360.0) / 0.25).round() as usize;
823            assert!(record < super::EGM96_DAC_N_LAT);
824            assert!(col < super::EGM96_DAC_N_LON);
825            let off = (record * super::EGM96_DAC_N_LON + col) * 2;
826            bytes[off..off + 2].copy_from_slice(&cm.to_be_bytes());
827        }
828        bytes
829    }
830
831    #[test]
832    fn builtin_returns_exact_node_values() {
833        // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
834        assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
835        // (lat 0, lon 90 deg) is the Indian Ocean low node.
836        assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
837        // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
838        assert_eq!(
839            geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
840            60.0
841        );
842    }
843
844    #[test]
845    fn builtin_captures_major_geoid_features_by_sign() {
846        // The Indian Ocean is the global geoid low: undulation is strongly negative.
847        let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
848        assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
849        // The North Atlantic is a geoid high: undulation is positive.
850        let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
851        assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
852    }
853
854    #[test]
855    fn bilinear_midpoint_is_the_corner_average() {
856        let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
857        // Cell-center: equal weight to all four corners -> their mean.
858        let center = grid.undulation_deg(5.0, 5.0);
859        assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
860        // Edge midpoints interpolate along one axis only.
861        assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
862        assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
863        // Corners return the node values exactly.
864        assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
865        assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
866    }
867
868    #[test]
869    fn global_grid_wraps_across_the_antimeridian() {
870        // A global grid whose +180 column equals its -180 column interpolates
871        // continuously across the seam: two points a hair either side of the
872        // antimeridian return nearly the same undulation (no discontinuity).
873        let east = geoid_undulation(0.0, 179.999_f64.to_radians());
874        let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
875        assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
876        // The antimeridian node itself is -10 m on the equator row.
877        assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
878        assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
879        // Exactly +180 and -180 are the same physical meridian -> same value.
880        let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
881        let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
882        assert_eq!(plus, minus);
883        assert_eq!(plus, -10.0);
884    }
885
886    #[test]
887    fn orthometric_height_subtracts_undulation() {
888        let lat = 0.0;
889        let lon = 0.0;
890        let n = geoid_undulation(lat, lon);
891        assert_eq!(n, 17.0);
892        // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
893        assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
894        // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
895        assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
896    }
897
898    #[test]
899    fn egm96_height_converters_use_the_egm96_undulation() {
900        // A known point well away from the coarse-grid agreement; the egm96
901        // converters must subtract/add the genuine EGM96 1-degree undulation, not
902        // the coarse 30-degree built-in.
903        let lat = 37.0_f64.to_radians();
904        let lon = (-122.0_f64).to_radians();
905        let n = egm96_undulation(lat, lon);
906        let h = 250.0;
907        let big_h = egm96_orthometric_height_m(h, lat, lon);
908        assert_eq!(big_h, h - n);
909        assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
910        // The egm96 path differs from the coarse path here (different model).
911        assert_ne!(
912            egm96_orthometric_height_m(h, lat, lon),
913            orthometric_height_m(h, lat, lon)
914        );
915    }
916
917    #[test]
918    fn batch_undulation_entries_match_scalar_lookup() {
919        let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
920        let got_deg = egm96_undulations_deg(&points_deg);
921        let expected_deg: Vec<f64> = points_deg
922            .iter()
923            .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
924            .collect();
925        assert_eq!(got_deg, expected_deg);
926
927        let points_rad: Vec<(f64, f64)> = points_deg
928            .iter()
929            .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
930            .collect();
931        let got_rad = egm96_undulations_rad(&points_rad);
932        let expected_rad: Vec<f64> = points_rad
933            .iter()
934            .map(|&(lat, lon)| egm96_undulation(lat, lon))
935            .collect();
936        assert_eq!(got_rad, expected_rad);
937
938        assert_eq!(
939            geoid_undulations_deg(&points_deg),
940            points_deg
941                .iter()
942                .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
943                .collect::<Vec<_>>()
944        );
945    }
946
947    #[test]
948    fn from_text_round_trips_a_grid() {
949        let text = "\
950# coarse 2x3 regional grid
951# lat_min lon_min dlat dlon n_lat n_lon
95210.0 20.0 5.0 5.0 2 3
953  1.0  2.0  3.0   # lat 10 row
954  4.0  5.0  6.0   # lat 15 row
955";
956        let grid = GeoidGrid::from_text(text).expect("parse grid");
957        assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
958        assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
959        // Cell center of the lower-left cell -> mean of the four corners.
960        let center = grid.undulation_deg(12.5, 22.5);
961        assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
962        // A regional grid clamps rather than wraps outside its longitude span.
963        assert_eq!(
964            grid.undulation_deg(10.0, 0.0),
965            grid.undulation_deg(10.0, 20.0)
966        );
967    }
968
969    #[test]
970    fn from_text_rejects_short_data() {
971        let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
972        assert_eq!(
973            GeoidGrid::from_text(text),
974            Err(GeoidError::InvalidDimensions {
975                expected: 4,
976                found: 3
977            })
978        );
979    }
980
981    #[test]
982    fn new_rejects_bad_inputs() {
983        assert!(matches!(
984            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
985            Err(GeoidError::InvalidDimensions { .. })
986        ));
987        assert!(matches!(
988            GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
989            Err(GeoidError::InvalidSpacing { field: "dlat" })
990        ));
991        assert!(matches!(
992            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
993            Err(GeoidError::NonFiniteValue { index: 1 })
994        ));
995    }
996
997    #[test]
998    fn longitude_normalization_folds_into_half_open_interval() {
999        assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1000        assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1001        assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1002        assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1003    }
1004
1005    /// The embedded EGM96 1-degree grid returns its genuine node values exactly
1006    /// at integer-degree positions (a node query is an exact bilinear hit). The
1007    /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
1008    /// transcribed from the source grid; see the provenance note in this module.
1009    #[test]
1010    fn egm96_grid_reproduces_genuine_nodes() {
1011        // (lat_deg, lon_deg, expected EGM96 undulation in metres).
1012        let nodes: [(f64, f64, f64); 5] = [
1013            (0.0, 0.0, 17.16),    // Gulf of Guinea
1014            (0.0, 80.0, -102.69), // Indian Ocean low
1015            (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
1016            (-90.0, 0.0, -29.53), // south pole
1017            (90.0, 0.0, 13.61),   // north pole
1018        ];
1019        for (lat, lon, want) in nodes {
1020            let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1021            assert!(
1022                (got - want).abs() <= 1.0e-9,
1023                "egm96 node ({lat},{lon}): got {got}, want {want}"
1024            );
1025        }
1026    }
1027
1028    /// The embedded EGM96 grid matches the independently published EGM96 geoid
1029    /// height at a known checkpoint within the tolerance set by its 1-degree
1030    /// resolution, and is far closer to truth than the coarse built-in.
1031    ///
1032    /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
1033    /// `16:46:33N 3:00:34W` (Timbuktu); see
1034    /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
1035    /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
1036    /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
1037    /// published value, well inside a 1-degree-resolution tolerance.
1038    #[test]
1039    fn egm96_grid_matches_published_checkpoint() {
1040        let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1041        let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1042        let published = 28.7068;
1043
1044        let egm96 = egm96_undulation(lat, lon);
1045        assert!(
1046            (egm96 - published).abs() < 0.5,
1047            "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1048        );
1049
1050        // The genuine 1-degree grid must be strictly closer to the published
1051        // value than the decametre-scale 30-degree built-in.
1052        let coarse = geoid_undulation(lat, lon);
1053        assert!(
1054            (egm96 - published).abs() < (coarse - published).abs(),
1055            "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1056        );
1057    }
1058
1059    /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
1060    /// centimetres, north-to-south records flipped to latitude-ascending storage,
1061    /// longitude `0..359.75` E. Validated against an independently built grid of
1062    /// the same samples, plus the byte-length guard.
1063    #[test]
1064    fn from_egm96_dac_decodes_the_nga_layout() {
1065        let n_lat = super::EGM96_DAC_N_LAT;
1066        let n_lon = super::EGM96_DAC_N_LON;
1067        // A deterministic per-(record, column) pattern, well within int16 cm.
1068        let cm = |record: usize, col: usize| -> i16 {
1069            ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1070        };
1071
1072        let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1073        for record in 0..n_lat {
1074            for col in 0..n_lon {
1075                bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1076            }
1077        }
1078        let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1079
1080        // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
1081        // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
1082        let mut values_m = vec![0.0f64; n_lat * n_lon];
1083        for i in 0..n_lat {
1084            let record = n_lat - 1 - i;
1085            for col in 0..n_lon {
1086                values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1087            }
1088        }
1089        let expected =
1090            GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1091        assert_eq!(parsed, expected);
1092
1093        // A wrong byte length is rejected, not silently misread.
1094        assert!(matches!(
1095            GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1096            Err(GeoidError::Parse { .. })
1097        ));
1098    }
1099
1100    #[test]
1101    fn egm96_dac_sparse_fixture_matches_proj_oracle() {
1102        let bytes = sparse_egm96_dac_bytes();
1103        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1104        for fixture in PROJ_EGM96_FIXTURES {
1105            let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1106            assert!(
1107                (got - fixture.undulation_m).abs() <= 0.005,
1108                "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
1109                fixture.lat_deg,
1110                fixture.lon_deg,
1111                fixture.undulation_m
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn geoid_grid_height_conversions_pin_sign_convention() {
1118        let bytes = sparse_egm96_dac_bytes();
1119        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1120        for fixture in PROJ_EGM96_FIXTURES {
1121            let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1122            let h = 250.0;
1123            let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
1124            assert_eq!(orthometric, h - n);
1125            assert_eq!(
1126                grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
1127                orthometric + n
1128            );
1129
1130            let lat_rad = fixture.lat_deg.to_radians();
1131            let lon_rad = fixture.lon_deg.to_radians();
1132            assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
1133            assert!(
1134                (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
1135                    .abs()
1136                    <= 1.0e-12
1137            );
1138        }
1139    }
1140}