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//! - [`GeoidGrid::from_egm2008_raster`], a loader for the NGA EGM2008
23//!   row-framed `REAL*4` raster grids at 2.5-arcminute and 1-arcminute spacing;
24//! - [`egm96_undulation`] / [`egm96_grid`], a zero-setup lookup against an
25//!   embedded genuine EGM96 1-degree global grid (a higher-accuracy alternative
26//!   to the coarse built-in);
27//! - [`geoid_undulation`], a zero-setup lookup against a small COARSE built-in
28//!   global grid, plus [`orthometric_height_m`] / [`ellipsoidal_height_m`] height
29//!   conversion helpers.
30//!
31//! ## Choosing a grid
32//!
33//! Three accuracy tiers are available, in increasing fidelity:
34//!
35//! 1. [`geoid_undulation`] - the COARSE 30-degree built-in. It reproduces the
36//!    large-scale character of the geoid (the Indian Ocean low, the North
37//!    Atlantic / New Guinea highs, the polar offsets) and is fine for tests,
38//!    sanity checks, and metre-scale fallback, but it is NOT survey-grade
39//!    (decametre-level error).
40//! 2. [`egm96_undulation`] - an embedded GENUINE EGM96 1-degree global grid,
41//!    decimated from the official NGA 15-arcminute model. Its bilinear lookup
42//!    agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS (95th
43//!    percentile ~0.7 m; up to a few metres over the steepest geoid gradients).
44//!    This is the recommended zero-setup default for metre-class datum work.
45//! 3. [`GeoidGrid::from_egm96_dac`] with the official `WW15MGH.DAC` file (a
46//!    ~2 MB download, not vendored here) - the full 15-arcminute resolution. Its
47//!    bilinear lookup tracks the geoid to roughly decimetre RMS, but the
48//!    worst-case bilinear interpolation error can still exceed 1 m over the
49//!    steepest geoid gradients (see
50//!    <https://geographiclib.sourceforge.io/html/geoid.html> for the egm96-15
51//!    error envelope), so this path supports decimetre-class typical datum work
52//!    rather than guaranteed sub-metre accuracy everywhere. Embedding the full
53//!    grid is impractical (the 15-arcminute grid is ~1 M samples and EGM2008
54//!    1-minute is ~2.3 GB), so the high-resolution path loads the file at
55//!    runtime.
56//!
57//! A caller with any other vendor grid can lower it to [`GeoidGrid::from_text`]
58//! or build a [`GeoidGrid`] via [`GeoidGrid::new`] and call
59//! [`GeoidGrid::undulation_rad`] directly.
60
61use std::sync::OnceLock;
62
63/// Why a geoid grid could not be constructed or parsed.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum GeoidError {
66    /// A grid dimension was zero, or the value count did not equal `n_lat * n_lon`.
67    InvalidDimensions {
68        /// What was expected.
69        expected: usize,
70        /// What was supplied.
71        found: usize,
72    },
73    /// A grid spacing or origin was non-finite or non-positive.
74    InvalidSpacing {
75        /// The offending field.
76        field: &'static str,
77    },
78    /// A grid sample value was non-finite.
79    NonFiniteValue {
80        /// Row-major index of the offending sample.
81        index: usize,
82    },
83    /// The grid text could not be parsed.
84    Parse {
85        /// A human-readable reason.
86        reason: String,
87    },
88}
89
90impl core::fmt::Display for GeoidError {
91    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92        match self {
93            Self::InvalidDimensions { expected, found } => {
94                write!(
95                    f,
96                    "geoid grid expected {expected} samples but found {found}"
97                )
98            }
99            Self::InvalidSpacing { field } => {
100                write!(f, "geoid grid {field} must be finite and positive")
101            }
102            Self::NonFiniteValue { index } => {
103                write!(f, "geoid grid sample {index} is not finite")
104            }
105            Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
106        }
107    }
108}
109
110impl std::error::Error for GeoidError {}
111
112/// Supported NGA EGM2008 interpolation-raster spacings.
113///
114/// The official rasters store `REAL*4` geoid undulation samples in Fortran
115/// sequential records. Rows run north-to-south, columns run west-to-east from
116/// longitude `0` degrees east, and there is no duplicate `360` degree column.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Egm2008GridSpacing {
119    /// The 1-arcminute EGM2008 grid, `10801 x 21600` nodes.
120    OneMinute,
121    /// The 2.5-arcminute EGM2008 grid, `4321 x 8640` nodes.
122    TwoPointFiveMinute,
123}
124
125impl Egm2008GridSpacing {
126    /// Grid spacing in arcminutes.
127    pub fn arc_minutes(self) -> f64 {
128        match self {
129            Self::OneMinute => 1.0,
130            Self::TwoPointFiveMinute => 2.5,
131        }
132    }
133
134    /// Grid spacing in degrees.
135    pub fn degrees(self) -> f64 {
136        self.arc_minutes() / 60.0
137    }
138
139    /// Official global row and column counts for this spacing.
140    pub fn global_dimensions(self) -> (usize, usize) {
141        match self {
142            Self::OneMinute => (EGM2008_1_MIN_N_LAT, EGM2008_1_MIN_N_LON),
143            Self::TwoPointFiveMinute => (EGM2008_2P5_MIN_N_LAT, EGM2008_2P5_MIN_N_LON),
144        }
145    }
146}
147
148/// A full or cropped EGM2008 row-framed raster window.
149///
150/// The window describes the bytes passed to
151/// [`GeoidGrid::from_egm2008_raster_window`]. The byte stream contains one
152/// Fortran sequential record per latitude row, ordered north-to-south, with
153/// `n_lon` `REAL*4` samples per row. The resulting [`GeoidGrid`] stores rows
154/// latitude-ascending and uses the same bilinear interpolation path as every
155/// other geoid grid in this module.
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct Egm2008RasterWindow {
158    spacing: Egm2008GridSpacing,
159    lat_min_deg: f64,
160    lon_min_deg: f64,
161    n_lat: usize,
162    n_lon: usize,
163}
164
165impl Egm2008RasterWindow {
166    /// Build a window descriptor for EGM2008 row-framed raster bytes.
167    ///
168    /// `lat_min_deg` and `lon_min_deg` are the southwest node of the resulting
169    /// grid in degrees. `n_lat` and `n_lon` are the node counts in the supplied
170    /// byte stream. Returns [`GeoidError`] if a dimension is zero, an origin is
171    /// not finite, the latitude span falls outside `[-90, 90]`, or the longitude
172    /// span exceeds a full global revolution.
173    pub fn new(
174        spacing: Egm2008GridSpacing,
175        lat_min_deg: f64,
176        lon_min_deg: f64,
177        n_lat: usize,
178        n_lon: usize,
179    ) -> Result<Self, GeoidError> {
180        if n_lat == 0 || n_lon == 0 {
181            return Err(GeoidError::InvalidDimensions {
182                expected: 1,
183                found: 0,
184            });
185        }
186        if !lat_min_deg.is_finite() {
187            return Err(GeoidError::InvalidSpacing { field: "lat_min" });
188        }
189        if !lon_min_deg.is_finite() {
190            return Err(GeoidError::InvalidSpacing { field: "lon_min" });
191        }
192        let d = spacing.degrees();
193        let lat_max_deg = lat_min_deg + (n_lat as f64 - 1.0) * d;
194        if lat_min_deg < -90.0 - 1.0e-12 || lat_max_deg > 90.0 + 1.0e-12 {
195            return Err(GeoidError::Parse {
196                reason: format!(
197                    "EGM2008 latitude window [{lat_min_deg}, {lat_max_deg}] exceeds [-90, 90]"
198                ),
199            });
200        }
201        let lon_span_deg = n_lon as f64 * d;
202        if lon_span_deg > 360.0 + 1.0e-12 {
203            return Err(GeoidError::Parse {
204                reason: format!("EGM2008 longitude span {lon_span_deg} exceeds 360 degrees"),
205            });
206        }
207        Ok(Self {
208            spacing,
209            lat_min_deg,
210            lon_min_deg,
211            n_lat,
212            n_lon,
213        })
214    }
215
216    /// Build the official full-global EGM2008 window for a spacing.
217    pub fn global(spacing: Egm2008GridSpacing) -> Self {
218        let (n_lat, n_lon) = spacing.global_dimensions();
219        Self::new(spacing, -90.0, 0.0, n_lat, n_lon)
220            .expect("EGM2008 global raster dimensions are valid")
221    }
222
223    /// Raster spacing for this window.
224    pub fn spacing(self) -> Egm2008GridSpacing {
225        self.spacing
226    }
227
228    /// Southwest latitude of this window in degrees.
229    pub fn lat_min_deg(self) -> f64 {
230        self.lat_min_deg
231    }
232
233    /// Western longitude of this window in degrees.
234    pub fn lon_min_deg(self) -> f64 {
235        self.lon_min_deg
236    }
237
238    /// Latitude node count in this window.
239    pub fn n_lat(self) -> usize {
240        self.n_lat
241    }
242
243    /// Longitude node count in this window.
244    pub fn n_lon(self) -> usize {
245        self.n_lon
246    }
247}
248
249/// A regular latitude/longitude grid of geoid undulation samples (metres) with
250/// bilinear interpolation.
251///
252/// Samples are stored row-major with latitude ascending (outer) and longitude
253/// ascending (inner): `values_m[i * n_lon + j]` is the undulation at latitude
254/// `lat_min_deg + i * dlat_deg` and longitude `lon_min_deg + j * dlon_deg`.
255///
256/// Latitude inputs are clamped to the grid's latitude span. Longitude inputs are
257/// normalized to `[-180, 180)` and then, when the grid spans a full 360 degrees
258/// of longitude, wrapped across the antimeridian; otherwise they are clamped to
259/// the grid's longitude span (so a regional grid does not wrap).
260#[derive(Debug, Clone, PartialEq)]
261pub struct GeoidGrid {
262    lat_min_deg: f64,
263    lon_min_deg: f64,
264    dlat_deg: f64,
265    dlon_deg: f64,
266    n_lat: usize,
267    n_lon: usize,
268    values_m: Vec<f64>,
269}
270
271impl GeoidGrid {
272    /// Build a geoid grid from its origin, spacing, dimensions, and row-major
273    /// samples (metres).
274    ///
275    /// Returns [`GeoidError`] when a dimension is zero, the sample count does not
276    /// equal `n_lat * n_lon`, a spacing/origin is non-finite or a spacing is
277    /// non-positive, or a sample is non-finite.
278    pub fn new(
279        lat_min_deg: f64,
280        lon_min_deg: f64,
281        dlat_deg: f64,
282        dlon_deg: f64,
283        n_lat: usize,
284        n_lon: usize,
285        values_m: Vec<f64>,
286    ) -> Result<Self, GeoidError> {
287        if n_lat == 0 || n_lon == 0 {
288            return Err(GeoidError::InvalidDimensions {
289                expected: 1,
290                found: 0,
291            });
292        }
293        let expected = n_lat * n_lon;
294        if values_m.len() != expected {
295            return Err(GeoidError::InvalidDimensions {
296                expected,
297                found: values_m.len(),
298            });
299        }
300        if !lat_min_deg.is_finite() {
301            return Err(GeoidError::InvalidSpacing { field: "lat_min" });
302        }
303        if !lon_min_deg.is_finite() {
304            return Err(GeoidError::InvalidSpacing { field: "lon_min" });
305        }
306        if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
307            return Err(GeoidError::InvalidSpacing { field: "dlat" });
308        }
309        if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
310            return Err(GeoidError::InvalidSpacing { field: "dlon" });
311        }
312        for (index, value) in values_m.iter().enumerate() {
313            if !value.is_finite() {
314                return Err(GeoidError::NonFiniteValue { index });
315            }
316        }
317        Ok(Self {
318            lat_min_deg,
319            lon_min_deg,
320            dlat_deg,
321            dlon_deg,
322            n_lat,
323            n_lon,
324            values_m,
325        })
326    }
327
328    /// Parse a geoid grid from a simple, documented text format (the data-loading
329    /// hook for full EGM grids).
330    ///
331    /// The format is whitespace-delimited with `#` line comments. The first
332    /// non-comment token sequence is a six-field header:
333    ///
334    /// ```text
335    /// lat_min lon_min dlat dlon n_lat n_lon
336    /// ```
337    ///
338    /// followed by exactly `n_lat * n_lon` undulation samples in metres, in
339    /// row-major order (latitude ascending outer, longitude ascending inner).
340    /// All angles are in degrees. This is deliberately a minimal, line-oriented
341    /// format; a caller converting a vendor grid (EGM `.gri`/`.ndp`, a GeoTIFF,
342    /// etc.) lowers it to this shape or builds a [`GeoidGrid`] via [`new`].
343    ///
344    /// [`new`]: GeoidGrid::new
345    pub fn from_text(text: &str) -> Result<Self, GeoidError> {
346        let mut tokens = text
347            .lines()
348            .map(|line| line.split('#').next().unwrap_or(""))
349            .flat_map(str::split_whitespace);
350
351        let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
352            let token = tokens.next().ok_or_else(|| GeoidError::Parse {
353                reason: format!("missing header field {field}"),
354            })?;
355            token.parse::<f64>().map_err(|_| GeoidError::Parse {
356                reason: format!("header field {field} is not a number: {token:?}"),
357            })
358        };
359
360        let lat_min_deg = next_field("lat_min")?;
361        let lon_min_deg = next_field("lon_min")?;
362        let dlat_deg = next_field("dlat")?;
363        let dlon_deg = next_field("dlon")?;
364        let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
365        let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
366
367        let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
368            reason: "n_lat * n_lon overflows".to_string(),
369        })?;
370        let mut values_m = Vec::with_capacity(expected);
371        for token in tokens {
372            let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
373                reason: format!("sample is not a number: {token:?}"),
374            })?;
375            values_m.push(value);
376        }
377
378        Self::new(
379            lat_min_deg,
380            lon_min_deg,
381            dlat_deg,
382            dlon_deg,
383            n_lat,
384            n_lon,
385            values_m,
386        )
387    }
388
389    /// Parse the authoritative NGA EGM96 15-arcminute binary geoid grid
390    /// (`WW15MGH.DAC`) for decimetre-class datum work.
391    ///
392    /// This is the highest-resolution path in the module. Its bilinear lookup
393    /// tracks the geoid to roughly decimetre RMS, but the worst-case bilinear
394    /// interpolation error can still exceed 1 m over the steepest geoid
395    /// gradients, so it does not guarantee sub-metre accuracy everywhere.
396    ///
397    /// The file is a headerless block of `721 * 1440` big-endian `INTEGER*2`
398    /// samples in centimetres, arranged north-to-south by record (record 1 at
399    /// latitude `+90`, last record at `-90`, in `0.25`-degree steps) and, within
400    /// each record, west-to-east by longitude from `0` to `359.75` degrees in
401    /// `0.25`-degree steps. Each sample is divided by 100 to get metres. The rows
402    /// are flipped to the latitude-ascending storage order of [`GeoidGrid`], so
403    /// the resulting grid is global in longitude and wraps across the
404    /// antimeridian like any other full-span grid.
405    ///
406    /// The file is not vendored in this crate (it is a ~2 MB public-domain NGA
407    /// download); fetch `WW15MGH.DAC` from the NGA EGM96 distribution and pass its
408    /// bytes here. For a zero-setup metre-class default without the download, use
409    /// [`egm96_undulation`] instead.
410    ///
411    /// Returns [`GeoidError::Parse`] if the byte length is not exactly
412    /// `721 * 1440 * 2` bytes.
413    pub fn from_egm96_dac(bytes: &[u8]) -> Result<Self, GeoidError> {
414        let expected = EGM96_DAC_N_LAT * EGM96_DAC_N_LON * 2;
415        if bytes.len() != expected {
416            return Err(GeoidError::Parse {
417                reason: format!(
418                    "EGM96 WW15MGH.DAC must be {expected} bytes ({EGM96_DAC_N_LAT} x {EGM96_DAC_N_LON} big-endian int16), got {}",
419                    bytes.len()
420                ),
421            });
422        }
423        let mut values_m = vec![0.0f64; EGM96_DAC_N_LAT * EGM96_DAC_N_LON];
424        for i in 0..EGM96_DAC_N_LAT {
425            // DAC record 0 is +90 (north); GeoidGrid stores latitude ascending,
426            // so internal row i (latitude -90 + i*0.25) reads DAC record N-1-i.
427            let src_row = EGM96_DAC_N_LAT - 1 - i;
428            for c in 0..EGM96_DAC_N_LON {
429                let off = (src_row * EGM96_DAC_N_LON + c) * 2;
430                let cm = i16::from_be_bytes([bytes[off], bytes[off + 1]]);
431                values_m[i * EGM96_DAC_N_LON + c] = f64::from(cm) / 100.0;
432            }
433        }
434        Self::new(
435            -90.0,
436            0.0,
437            0.25,
438            0.25,
439            EGM96_DAC_N_LAT,
440            EGM96_DAC_N_LON,
441            values_m,
442        )
443    }
444
445    /// Parse an official full-global NGA EGM2008 interpolation raster.
446    ///
447    /// The byte stream must be the `Und_min1x1_...` or `Und_min2.5x2.5_...`
448    /// raster for the supplied spacing. Both the original big-endian files and
449    /// the NGA small-endian variants are accepted. Each row is a Fortran
450    /// sequential record whose leading and trailing record lengths must match
451    /// `n_lon * 4` bytes, and each sample is decoded as a finite `REAL*4`
452    /// undulation in metres.
453    ///
454    /// Use [`GeoidGrid::from_egm2008_raster_window`] when validating or loading
455    /// a cropped raster window with the same record layout.
456    pub fn from_egm2008_raster(
457        bytes: &[u8],
458        spacing: Egm2008GridSpacing,
459    ) -> Result<Self, GeoidError> {
460        Self::from_egm2008_raster_window(bytes, Egm2008RasterWindow::global(spacing))
461    }
462
463    /// Parse a full or cropped NGA EGM2008 interpolation raster window.
464    ///
465    /// `window` supplies the grid spacing, southwest node, and node dimensions.
466    /// The byte stream must contain exactly one north-to-south Fortran
467    /// sequential record per latitude row, with `n_lon` `REAL*4` samples in each
468    /// row. Both big-endian and small-endian record/sample encodings are
469    /// accepted and are detected from the first record marker.
470    pub fn from_egm2008_raster_window(
471        bytes: &[u8],
472        window: Egm2008RasterWindow,
473    ) -> Result<Self, GeoidError> {
474        let values_m = parse_egm2008_raster_values(bytes, window)?;
475        Self::new(
476            window.lat_min_deg,
477            window.lon_min_deg,
478            window.spacing.degrees(),
479            window.spacing.degrees(),
480            window.n_lat,
481            window.n_lon,
482            values_m,
483        )
484    }
485
486    /// Whether the grid spans a full 360 degrees of longitude (and therefore
487    /// wraps across the antimeridian during interpolation).
488    fn is_global_longitude(&self) -> bool {
489        ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
490            || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
491    }
492
493    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
494    /// radians (latitude positive north, longitude positive east).
495    pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
496        self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
497    }
498
499    /// Batch bilinear undulation lookup for geodetic positions in radians.
500    ///
501    /// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
502    /// longitude positive east. Output element `i` is exactly the scalar
503    /// [`undulation_rad`](Self::undulation_rad) result for input element `i`.
504    pub fn undulations_rad(&self, points_rad: &[(f64, f64)]) -> Vec<f64> {
505        points_rad
506            .iter()
507            .map(|&(lat_rad, lon_rad)| self.undulation_rad(lat_rad, lon_rad))
508            .collect()
509    }
510
511    /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
512    /// degrees (latitude positive north, longitude positive east).
513    pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
514        let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
515        let (i0, i1, ty) = self.lat_bracket(lat);
516
517        let (j0, j1, tx) = self.lon_bracket(lon_deg);
518
519        let v00 = self.sample(i0, j0);
520        let v01 = self.sample(i0, j1);
521        let v10 = self.sample(i1, j0);
522        let v11 = self.sample(i1, j1);
523
524        let bottom = v00 + (v01 - v00) * tx;
525        let top = v10 + (v11 - v10) * tx;
526        bottom + (top - bottom) * ty
527    }
528
529    /// Batch bilinear undulation lookup for geodetic positions in degrees.
530    ///
531    /// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
532    /// longitude positive east. Output element `i` is exactly the scalar
533    /// [`undulation_deg`](Self::undulation_deg) result for input element `i`.
534    pub fn undulations_deg(&self, points_deg: &[(f64, f64)]) -> Vec<f64> {
535        points_deg
536            .iter()
537            .map(|&(lat_deg, lon_deg)| self.undulation_deg(lat_deg, lon_deg))
538            .collect()
539    }
540
541    /// Orthometric height `H = h - N` (metres above mean sea level) from an
542    /// ellipsoidal height and a geodetic position in radians, using this grid's
543    /// undulation.
544    pub fn orthometric_height_rad(
545        &self,
546        ellipsoidal_height_m: f64,
547        lat_rad: f64,
548        lon_rad: f64,
549    ) -> f64 {
550        ellipsoidal_height_m - self.undulation_rad(lat_rad, lon_rad)
551    }
552
553    /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
554    /// orthometric height and a geodetic position in radians, using this grid's
555    /// undulation.
556    pub fn ellipsoidal_height_rad(
557        &self,
558        orthometric_height_m: f64,
559        lat_rad: f64,
560        lon_rad: f64,
561    ) -> f64 {
562        orthometric_height_m + self.undulation_rad(lat_rad, lon_rad)
563    }
564
565    /// Orthometric height `H = h - N` (metres above mean sea level) from an
566    /// ellipsoidal height and a geodetic position in degrees, using this grid's
567    /// undulation.
568    pub fn orthometric_height_deg(
569        &self,
570        ellipsoidal_height_m: f64,
571        lat_deg: f64,
572        lon_deg: f64,
573    ) -> f64 {
574        ellipsoidal_height_m - self.undulation_deg(lat_deg, lon_deg)
575    }
576
577    /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
578    /// orthometric height and a geodetic position in degrees, using this grid's
579    /// undulation.
580    pub fn ellipsoidal_height_deg(
581        &self,
582        orthometric_height_m: f64,
583        lat_deg: f64,
584        lon_deg: f64,
585    ) -> f64 {
586        orthometric_height_m + self.undulation_deg(lat_deg, lon_deg)
587    }
588
589    fn lat_max_deg(&self) -> f64 {
590        self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
591    }
592
593    /// Latitude bracketing cell indices and fractional position within the cell.
594    fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
595        if self.n_lat == 1 {
596            return (0, 0, 0.0);
597        }
598        let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
599        let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
600        let i0 = (pos.floor() as usize).min(self.n_lat - 2);
601        (i0, i0 + 1, pos - i0 as f64)
602    }
603
604    /// Longitude bracketing cell indices and fractional position within the cell.
605    /// Wraps across the antimeridian for a global grid; clamps for a regional one.
606    fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
607        if self.n_lon == 1 {
608            return (0, 0, 0.0);
609        }
610        let lon = normalize_longitude_deg(lon_deg);
611        if self.is_global_longitude() {
612            let span = self.n_lon as f64 * self.dlon_deg;
613            let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
614            // Guard the rare case where rounding lands offset exactly on span.
615            if offset >= span {
616                offset -= span;
617            }
618            let pos = offset / self.dlon_deg;
619            let j0 = (pos.floor() as usize) % self.n_lon;
620            let j1 = (j0 + 1) % self.n_lon;
621            (j0, j1, pos - pos.floor())
622        } else {
623            let pos =
624                ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
625            let j0 = (pos.floor() as usize).min(self.n_lon - 2);
626            (j0, j0 + 1, pos - j0 as f64)
627        }
628    }
629
630    fn sample(&self, i: usize, j: usize) -> f64 {
631        self.values_m[i * self.n_lon + j]
632    }
633}
634
635/// Parse a non-negative grid count from a float token.
636fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
637    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
638        return Err(GeoidError::Parse {
639            reason: format!("{field} must be a positive integer, got {value}"),
640        });
641    }
642    Ok(value as usize)
643}
644
645/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
646fn normalize_longitude_deg(lon_deg: f64) -> f64 {
647    let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
648    // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
649    if wrapped >= 180.0 {
650        wrapped - 360.0
651    } else {
652        wrapped
653    }
654}
655
656/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
657/// position in radians, from the COARSE built-in global grid.
658///
659/// Latitude is positive north, longitude positive east, both in radians. See
660/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
661/// load a real model with [`GeoidGrid::from_text`] and call
662/// [`GeoidGrid::undulation_rad`].
663pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
664    builtin_grid().undulation_rad(lat_rad, lon_rad)
665}
666
667/// Batch geoid undulation lookup against the COARSE built-in global grid.
668///
669/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
670/// longitude positive east. Output element `i` is exactly the scalar
671/// [`geoid_undulation`] result for input element `i`.
672pub fn geoid_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
673    builtin_grid().undulations_rad(points_rad)
674}
675
676/// Batch geoid undulation lookup against the COARSE built-in global grid.
677///
678/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
679/// longitude positive east.
680pub fn geoid_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
681    builtin_grid().undulations_deg(points_deg)
682}
683
684/// Orthometric height `H = h - N` (metres above mean sea level) from an
685/// ellipsoidal height and a geodetic position in radians, using the COARSE
686/// 30-degree built-in model's undulation (decametre-level error, NOT
687/// survey-grade). For metre-class conversion use [`egm96_orthometric_height_m`];
688/// for a real model, subtract [`GeoidGrid::undulation_rad`] directly.
689pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
690    ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
691}
692
693/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
694/// orthometric height and a geodetic position in radians, using the COARSE
695/// 30-degree built-in model's undulation (decametre-level error, NOT
696/// survey-grade). For metre-class conversion use [`egm96_ellipsoidal_height_m`];
697/// for a real model, add [`GeoidGrid::undulation_rad`] directly.
698pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
699    orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
700}
701
702/// Orthometric height `H = h - N` (metres above mean sea level) from an
703/// ellipsoidal height and a geodetic position in radians, using the embedded
704/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
705///
706/// This is the recommended zero-setup height converter for metre-class datum
707/// work; the [`orthometric_height_m`] sibling uses the COARSE 30-degree built-in
708/// instead and is only suitable for sanity checks.
709pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
710    ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
711}
712
713/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
714/// orthometric height and a geodetic position in radians, using the embedded
715/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
716///
717/// This is the recommended zero-setup height converter for metre-class datum
718/// work; the [`ellipsoidal_height_m`] sibling uses the COARSE 30-degree built-in
719/// instead and is only suitable for sanity checks.
720pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
721    orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
722}
723
724/// Latitude record count of the NGA EGM96 `WW15MGH.DAC` 15-arcminute grid.
725const EGM96_DAC_N_LAT: usize = 721;
726/// Longitude sample count per record of the NGA EGM96 `WW15MGH.DAC` grid.
727const EGM96_DAC_N_LON: usize = 1440;
728
729/// Latitude row count of the NGA EGM2008 1-arcminute raster.
730const EGM2008_1_MIN_N_LAT: usize = 10801;
731/// Longitude column count of the NGA EGM2008 1-arcminute raster.
732const EGM2008_1_MIN_N_LON: usize = 21600;
733/// Latitude row count of the NGA EGM2008 2.5-arcminute raster.
734const EGM2008_2P5_MIN_N_LAT: usize = 4321;
735/// Longitude column count of the NGA EGM2008 2.5-arcminute raster.
736const EGM2008_2P5_MIN_N_LON: usize = 8640;
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739enum RasterEndian {
740    Little,
741    Big,
742}
743
744impl RasterEndian {
745    fn read_u32(self, bytes: [u8; 4]) -> u32 {
746        match self {
747            Self::Little => u32::from_le_bytes(bytes),
748            Self::Big => u32::from_be_bytes(bytes),
749        }
750    }
751
752    fn read_f32(self, bytes: [u8; 4]) -> f32 {
753        match self {
754            Self::Little => f32::from_le_bytes(bytes),
755            Self::Big => f32::from_be_bytes(bytes),
756        }
757    }
758}
759
760fn parse_egm2008_raster_values(
761    bytes: &[u8],
762    window: Egm2008RasterWindow,
763) -> Result<Vec<f64>, GeoidError> {
764    let row_value_bytes = window
765        .n_lon
766        .checked_mul(4)
767        .ok_or_else(|| GeoidError::Parse {
768            reason: "EGM2008 row byte count overflows".to_string(),
769        })?;
770    let row_record_bytes = row_value_bytes
771        .checked_add(8)
772        .ok_or_else(|| GeoidError::Parse {
773            reason: "EGM2008 record byte count overflows".to_string(),
774        })?;
775    let expected = window
776        .n_lat
777        .checked_mul(row_record_bytes)
778        .ok_or_else(|| GeoidError::Parse {
779            reason: "EGM2008 raster byte count overflows".to_string(),
780        })?;
781    if bytes.len() != expected {
782        return Err(GeoidError::Parse {
783            reason: format!(
784                "EGM2008 raster window must be {expected} bytes ({} x {} REAL*4 row records), got {}",
785                window.n_lat,
786                window.n_lon,
787                bytes.len()
788            ),
789        });
790    }
791
792    let row_marker = u32::try_from(row_value_bytes).map_err(|_| GeoidError::Parse {
793        reason: "EGM2008 row marker exceeds u32".to_string(),
794    })?;
795    let endian = detect_egm2008_endian(bytes, row_marker)?;
796    let mut values_m = vec![0.0f64; window.n_lat * window.n_lon];
797    for src_row in 0..window.n_lat {
798        let row_off = src_row * row_record_bytes;
799        let start_marker = endian.read_u32([
800            bytes[row_off],
801            bytes[row_off + 1],
802            bytes[row_off + 2],
803            bytes[row_off + 3],
804        ]);
805        let end_off = row_off + 4 + row_value_bytes;
806        let end_marker = endian.read_u32([
807            bytes[end_off],
808            bytes[end_off + 1],
809            bytes[end_off + 2],
810            bytes[end_off + 3],
811        ]);
812        if start_marker != row_marker || end_marker != row_marker {
813            return Err(GeoidError::Parse {
814                reason: format!(
815                    "EGM2008 record {src_row} marker mismatch: start {start_marker}, end {end_marker}, expected {row_marker}"
816                ),
817            });
818        }
819        let dst_row = window.n_lat - 1 - src_row;
820        for col in 0..window.n_lon {
821            let sample_off = row_off + 4 + col * 4;
822            let value = endian.read_f32([
823                bytes[sample_off],
824                bytes[sample_off + 1],
825                bytes[sample_off + 2],
826                bytes[sample_off + 3],
827            ]);
828            let index = dst_row * window.n_lon + col;
829            if !value.is_finite() {
830                return Err(GeoidError::NonFiniteValue { index });
831            }
832            values_m[index] = f64::from(value);
833        }
834    }
835    Ok(values_m)
836}
837
838fn detect_egm2008_endian(bytes: &[u8], row_marker: u32) -> Result<RasterEndian, GeoidError> {
839    if bytes.len() < 4 {
840        return Err(GeoidError::Parse {
841            reason: "EGM2008 raster is too short for a record marker".to_string(),
842        });
843    }
844    let first = [bytes[0], bytes[1], bytes[2], bytes[3]];
845    let little = u32::from_le_bytes(first) == row_marker;
846    let big = u32::from_be_bytes(first) == row_marker;
847    match (little, big) {
848        (true, false) => Ok(RasterEndian::Little),
849        (false, true) => Ok(RasterEndian::Big),
850        (true, true) => Err(GeoidError::Parse {
851            reason: "EGM2008 record marker has ambiguous byte order".to_string(),
852        }),
853        (false, false) => Err(GeoidError::Parse {
854            reason: format!("EGM2008 first record marker does not match {row_marker}"),
855        }),
856    }
857}
858
859/// Latitude row count of the embedded genuine EGM96 1-degree grid.
860const EGM96_1DEG_N_LAT: usize = 181;
861/// Longitude column count of the embedded genuine EGM96 1-degree grid.
862const EGM96_1DEG_N_LON: usize = 360;
863
864// Provenance of the embedded EGM96 1-degree undulation grid
865// (`egm96_geoid_1deg.bin`):
866//
867// Source model: EGM96 (Earth Gravitational Model 1996), the joint NIMA (now
868// NGA) / NASA GSFC / Ohio State University global geopotential model. The geoid
869// undulation grid is a work of the U.S. Government and is in the public domain;
870// NGA distributes it without restriction. See THIRD-PARTY-NOTICES.md.
871//
872// Origin file: the official NGA 15-arcminute binary grid `WW15MGH.DAC`
873// (721 x 1440 big-endian INTEGER*2 centimetres, north-to-south records,
874// longitude 0..359.75 E), obtained from the public OpenSGeo PROJ vdatum mirror
875// (download.osgeo.org/proj/vdatum/egm96_15/). `egm96_geoid_1deg.bin` is that
876// grid decimated to a 1-degree lattice: each sample is the exact `WW15MGH.DAC`
877// value at the corresponding integer-degree node (no resampling or smoothing -
878// 1 degree is an integer multiple of the 0.25-degree source spacing), so every
879// value is a genuine EGM96 undulation, not a fabricated or fitted figure. The
880// packed format is 181 x 360 big-endian INTEGER*2 centimetres in
881// latitude-ascending (-90..+90), longitude-ascending (0..359 E) row-major order.
882// Decimating to 1 degree keeps the embedded data tractable (~127 KB) while its
883// bilinear lookup tracks the full 15-arcminute grid to ~0.4 m RMS.
884
885/// Bytes of the embedded genuine EGM96 1-degree undulation grid (big-endian
886/// int16 centimetres, latitude-ascending, longitude-ascending row-major).
887const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
888
889/// The embedded genuine EGM96 1-degree global geoid, decoded once on first use.
890///
891/// See [`egm96_undulation`] for the recommended scalar entry point and the
892/// module docs for the accuracy tiers.
893pub fn egm96_grid() -> &'static GeoidGrid {
894    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
895    GRID.get_or_init(|| {
896        assert_eq!(
897            EGM96_1DEG_BYTES.len(),
898            EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
899            "embedded EGM96 1-degree grid has the wrong byte length"
900        );
901        let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
902        for (k, value) in values_m.iter_mut().enumerate() {
903            let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
904            *value = f64::from(cm) / 100.0;
905        }
906        GeoidGrid::new(
907            -90.0,
908            0.0,
909            1.0,
910            1.0,
911            EGM96_1DEG_N_LAT,
912            EGM96_1DEG_N_LON,
913            values_m,
914        )
915        .expect("embedded EGM96 1-degree grid is well-formed")
916    })
917}
918
919/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
920/// position in radians, from the embedded GENUINE EGM96 1-degree global grid.
921///
922/// Latitude is positive north, longitude positive east, both in radians. This is
923/// the recommended zero-setup default for metre-class datum work: its bilinear
924/// lookup agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS. For the
925/// full-resolution model load the official `WW15MGH.DAC` via
926/// [`GeoidGrid::from_egm96_dac`]; for the lowest-fidelity legacy fallback use
927/// [`geoid_undulation`].
928pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
929    egm96_grid().undulation_rad(lat_rad, lon_rad)
930}
931
932/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
933/// global grid.
934///
935/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
936/// longitude positive east. Output element `i` is exactly the scalar
937/// [`egm96_undulation`] result for input element `i`.
938pub fn egm96_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
939    egm96_grid().undulations_rad(points_rad)
940}
941
942/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
943/// global grid.
944///
945/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
946/// longitude positive east.
947pub fn egm96_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
948    egm96_grid().undulations_deg(points_deg)
949}
950
951/// The coarse 30-degree built-in global geoid, built once on first use.
952fn builtin_grid() -> &'static GeoidGrid {
953    static GRID: OnceLock<GeoidGrid> = OnceLock::new();
954    GRID.get_or_init(|| {
955        GeoidGrid::new(
956            -90.0,
957            -180.0,
958            30.0,
959            30.0,
960            BUILTIN_N_LAT,
961            BUILTIN_N_LON,
962            BUILTIN_VALUES_M.to_vec(),
963        )
964        .expect("built-in geoid grid is well-formed")
965    })
966}
967
968const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
969const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
970
971/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
972/// ascending then longitude ascending. The values approximate the large-scale
973/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
974/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
975/// longitude columns coincide on the antimeridian so the global wrap is
976/// continuous.
977#[rustfmt::skip]
978const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
979    // lat = -90 (south pole)
980    -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,
981    // lat = -60
982    -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,
983    // lat = -30
984     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,
985    // lat = 0 (equator)
986    -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,
987    // lat = 30
988      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,
989    // lat = 60
990      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,
991    // lat = 90 (north pole)
992     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,
993];
994
995#[cfg(test)]
996mod tests {
997    //! Geoid validation provenance:
998    //!
999    //! EGM96 PROJ fixtures use `us_nga_egm96_15.tif` through PROJ `cct` and
1000    //! assert 5 mm agreement with sparse real `WW15MGH.DAC` centimetre nodes.
1001    //!
1002    //! EGM2008 fixtures use the public NGA `EGM2008_Interpolation_Grid.zip`
1003    //! archive from `https://earth-info.nga.mil/php/download.php?file=egm-08interpolation`.
1004    //! Archive SHA-256:
1005    //! `0f65f16e6fd3f89a6b8022d7a89375d0c29fb275a551927175669bb610904cd0`.
1006    //! Source member:
1007    //! `Und_min2.5x2.5_egm2008_isw=82_WGS84_TideFree_SE`.
1008    //! Source raster SHA-256:
1009    //! `ab6f8b94076f78707d1cdae7b066b93a786a0c64b52449e20cf1a1a2f4e74daf`.
1010    //! Crop fixture:
1011    //! `tests/fixtures/geoid/egm2008_25_norcal_crop.bin`, 25 x 25 nodes,
1012    //! 2.5 arcminute spacing, latitude 37.0 to 38.0 degrees, longitude
1013    //! -123.0 to -122.0 degrees. Crop SHA-256:
1014    //! `e66da6cbde7bb4015dc8b9c436fd93f16af3734e97017700fa3ab632f71f569d`.
1015    //! The crop preserves the NGA small-endian `REAL*4` row records for those
1016    //! nodes, with record lengths reduced to the cropped row width.
1017    //!
1018    //! EGM2008 oracle values use PROJ 9.8.1 `cct` with
1019    //! `us_nga_egm08_25.tif`, SHA-256
1020    //! `4191d471eefebf24091b56dbc604353cb3b8cf8cc70e448bb9ae56a272bef17a`.
1021    //! Command:
1022    //! `PROJ_DATA=/Volumes/ExternalSSD/sidereon-fleet/.tmp-egm2008/proj cct -d 12 +proj=pipeline +step +inv +proj=vgridshift +grids=us_nga_egm08_25.tif +multiplier=1`.
1023    //! With input height zero, the undulation is `-output_z`. The crop test
1024    //! asserts agreement to 5 mm.
1025
1026    use super::*;
1027
1028    #[derive(Clone, Copy)]
1029    struct ProjGeoidFixture {
1030        lat_deg: f64,
1031        lon_deg: f64,
1032        undulation_m: f64,
1033    }
1034
1035    const EGM2008_NORCAL_CROP_BYTES: &[u8] =
1036        include_bytes!("../tests/fixtures/geoid/egm2008_25_norcal_crop.bin");
1037
1038    // PROJ oracle provenance for the 15-arcminute EGM96 fixture below:
1039    //
1040    // Tool: PROJ 9.8.1 (`cct`, Rel. 9.8.1, April 10th, 2026).
1041    // Grid: `us_nga_egm96_15.tif`, fetched with
1042    // `projsync --target-dir /tmp/sidereon-proj-egm96 --file us_nga_egm96_15.tif`.
1043    // Grid SHA-256:
1044    // db493027562c9b004d7220fa881f5603adada4e1c5029b933fa7de4547b0e78d.
1045    // Command:
1046    // `PROJ_DATA=/tmp/sidereon-proj-egm96 cct -d 12 +proj=pipeline +step +inv
1047    //  +proj=vgridshift +grids=us_nga_egm96_15.tif +multiplier=1`.
1048    //
1049    // `cct` returns orthometric height for an ellipsoidal-height input. With
1050    // input height 0, the geoid undulation is `-output_z`.
1051    const PROJ_EGM96_FIXTURES: &[ProjGeoidFixture] = &[
1052        ProjGeoidFixture {
1053            lat_deg: 0.000000,
1054            lon_deg: 0.000000,
1055            undulation_m: 17.161579132080,
1056        },
1057        ProjGeoidFixture {
1058            lat_deg: 0.000000,
1059            lon_deg: 80.000000,
1060            undulation_m: -102.687904357910,
1061        },
1062        ProjGeoidFixture {
1063            lat_deg: 60.000000,
1064            lon_deg: -30.000000,
1065            undulation_m: 63.799266815186,
1066        },
1067        ProjGeoidFixture {
1068            lat_deg: 45.625000,
1069            lon_deg: 12.375000,
1070            undulation_m: 44.181870460510,
1071        },
1072        ProjGeoidFixture {
1073            lat_deg: 0.125000,
1074            lon_deg: 179.875000,
1075            undulation_m: 21.099070549011,
1076        },
1077        ProjGeoidFixture {
1078            lat_deg: 0.125000,
1079            lon_deg: -179.875000,
1080            undulation_m: 20.864660263062,
1081        },
1082        ProjGeoidFixture {
1083            lat_deg: -10.500000,
1084            lon_deg: 179.990000,
1085            undulation_m: 38.607539978027,
1086        },
1087        ProjGeoidFixture {
1088            lat_deg: -10.500000,
1089            lon_deg: -179.990000,
1090            undulation_m: 38.540365447998,
1091        },
1092        ProjGeoidFixture {
1093            lat_deg: 89.875000,
1094            lon_deg: 45.000000,
1095            undulation_m: 13.639517307281,
1096        },
1097        ProjGeoidFixture {
1098            lat_deg: -89.875000,
1099            lon_deg: 123.625000,
1100            undulation_m: -29.676423549652,
1101        },
1102        ProjGeoidFixture {
1103            lat_deg: 37.774900,
1104            lon_deg: -122.419400,
1105            undulation_m: -32.242452185586,
1106        },
1107    ];
1108
1109    const PROJ_EGM2008_FIXTURES: &[ProjGeoidFixture] = &[
1110        ProjGeoidFixture {
1111            lat_deg: 37.774900,
1112            lon_deg: -122.419400,
1113            undulation_m: -32.163558372373,
1114        },
1115        ProjGeoidFixture {
1116            lat_deg: 37.500000,
1117            lon_deg: -122.750000,
1118            undulation_m: -33.605857849121,
1119        },
1120        ProjGeoidFixture {
1121            lat_deg: 37.875000,
1122            lon_deg: -122.125000,
1123            undulation_m: -31.847370147705,
1124        },
1125        ProjGeoidFixture {
1126            lat_deg: 38.000000,
1127            lon_deg: -122.000000,
1128            undulation_m: -31.767843246460,
1129        },
1130        ProjGeoidFixture {
1131            lat_deg: 37.000000,
1132            lon_deg: -123.000000,
1133            undulation_m: -36.499370574951,
1134        },
1135    ];
1136
1137    // Real EGM96 15-arcminute node values, rounded to the centimetre grid the
1138    // NGA `WW15MGH.DAC` format stores. The sparse test grid writes only these
1139    // nodes into an otherwise-zero DAC-sized byte buffer; each oracle point
1140    // above falls in a cell whose four corners are present here. This avoids
1141    // committing the full 2 MB grid while still checking node registration,
1142    // antimeridian wrap, pole-row handling, and bilinear cell selection against
1143    // PROJ-derived values. The largest measured PROJ-vs-DAC-centimetre
1144    // difference in these fixtures is 0.0032 m.
1145    const SPARSE_EGM96_DAC_NODES_CM: &[(f64, f64, i16)] = &[
1146        (-90.00, 123.50, -2953),
1147        (-90.00, 123.75, -2953),
1148        (-89.75, 123.50, -2982),
1149        (-89.75, 123.75, -2982),
1150        (-10.50, 179.75, 3919),
1151        (-10.50, 180.00, 3858),
1152        (-10.50, 180.25, 3751),
1153        (-10.25, 179.75, 3733),
1154        (-10.25, 180.00, 3697),
1155        (-10.25, 180.25, 3611),
1156        (0.00, 0.00, 1716),
1157        (0.00, 0.25, 1708),
1158        (0.00, 80.00, -10269),
1159        (0.00, 80.25, -10255),
1160        (0.00, 179.75, 2138),
1161        (0.00, 180.00, 2115),
1162        (0.00, 180.25, 2095),
1163        (0.25, 0.00, 1719),
1164        (0.25, 0.25, 1711),
1165        (0.25, 80.00, -10286),
1166        (0.25, 80.25, -10276),
1167        (0.25, 179.75, 2109),
1168        (0.25, 180.00, 2078),
1169        (0.25, 180.25, 2058),
1170        (37.75, 237.50, -3237),
1171        (37.75, 237.75, -3204),
1172        (38.00, 237.50, -3211),
1173        (38.00, 237.75, -3200),
1174        (45.50, 12.25, 4398),
1175        (45.50, 12.50, 4355),
1176        (45.75, 12.25, 4498),
1177        (45.75, 12.50, 4421),
1178        (60.00, 330.00, 6380),
1179        (60.00, 330.25, 6400),
1180        (60.25, 330.00, 6365),
1181        (60.25, 330.25, 6388),
1182        (89.75, 45.00, 1367),
1183        (89.75, 45.25, 1367),
1184        (90.00, 45.00, 1361),
1185        (90.00, 45.25, 1361),
1186    ];
1187
1188    fn sparse_egm96_dac_bytes() -> Vec<u8> {
1189        let mut bytes = vec![0u8; super::EGM96_DAC_N_LAT * super::EGM96_DAC_N_LON * 2];
1190        for &(lat_deg, lon_east_deg, cm) in SPARSE_EGM96_DAC_NODES_CM {
1191            let record = ((90.0 - lat_deg) / 0.25).round() as usize;
1192            let col = (lon_east_deg.rem_euclid(360.0) / 0.25).round() as usize;
1193            assert!(record < super::EGM96_DAC_N_LAT);
1194            assert!(col < super::EGM96_DAC_N_LON);
1195            let off = (record * super::EGM96_DAC_N_LON + col) * 2;
1196            bytes[off..off + 2].copy_from_slice(&cm.to_be_bytes());
1197        }
1198        bytes
1199    }
1200
1201    fn egm2008_norcal_window() -> Egm2008RasterWindow {
1202        Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 37.0, -123.0, 25, 25)
1203            .expect("EGM2008 crop window")
1204    }
1205
1206    fn egm2008_test_raster_bytes(
1207        window: Egm2008RasterWindow,
1208        little_endian: bool,
1209        value: impl Fn(usize, usize) -> f32,
1210    ) -> Vec<u8> {
1211        let row_value_bytes = window.n_lon() * 4;
1212        let mut bytes = Vec::with_capacity(window.n_lat() * (row_value_bytes + 8));
1213        for src_row in 0..window.n_lat() {
1214            if little_endian {
1215                bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1216            } else {
1217                bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1218            }
1219            for col in 0..window.n_lon() {
1220                let sample = value(src_row, col);
1221                if little_endian {
1222                    bytes.extend_from_slice(&sample.to_le_bytes());
1223                } else {
1224                    bytes.extend_from_slice(&sample.to_be_bytes());
1225                }
1226            }
1227            if little_endian {
1228                bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1229            } else {
1230                bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1231            }
1232        }
1233        bytes
1234    }
1235
1236    #[test]
1237    fn builtin_returns_exact_node_values() {
1238        // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
1239        assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
1240        // (lat 0, lon 90 deg) is the Indian Ocean low node.
1241        assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
1242        // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
1243        assert_eq!(
1244            geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
1245            60.0
1246        );
1247    }
1248
1249    #[test]
1250    fn builtin_captures_major_geoid_features_by_sign() {
1251        // The Indian Ocean is the global geoid low: undulation is strongly negative.
1252        let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
1253        assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
1254        // The North Atlantic is a geoid high: undulation is positive.
1255        let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
1256        assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
1257    }
1258
1259    #[test]
1260    fn bilinear_midpoint_is_the_corner_average() {
1261        let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
1262        // Cell-center: equal weight to all four corners -> their mean.
1263        let center = grid.undulation_deg(5.0, 5.0);
1264        assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
1265        // Edge midpoints interpolate along one axis only.
1266        assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
1267        assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
1268        // Corners return the node values exactly.
1269        assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
1270        assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
1271    }
1272
1273    #[test]
1274    fn global_grid_wraps_across_the_antimeridian() {
1275        // A global grid whose +180 column equals its -180 column interpolates
1276        // continuously across the seam: two points a hair either side of the
1277        // antimeridian return nearly the same undulation (no discontinuity).
1278        let east = geoid_undulation(0.0, 179.999_f64.to_radians());
1279        let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
1280        assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
1281        // The antimeridian node itself is -10 m on the equator row.
1282        assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
1283        assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
1284        // Exactly +180 and -180 are the same physical meridian -> same value.
1285        let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
1286        let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
1287        assert_eq!(plus, minus);
1288        assert_eq!(plus, -10.0);
1289    }
1290
1291    #[test]
1292    fn orthometric_height_subtracts_undulation() {
1293        let lat = 0.0;
1294        let lon = 0.0;
1295        let n = geoid_undulation(lat, lon);
1296        assert_eq!(n, 17.0);
1297        // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
1298        assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
1299        // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
1300        assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
1301    }
1302
1303    #[test]
1304    fn egm96_height_converters_use_the_egm96_undulation() {
1305        // A known point well away from the coarse-grid agreement; the egm96
1306        // converters must subtract/add the genuine EGM96 1-degree undulation, not
1307        // the coarse 30-degree built-in.
1308        let lat = 37.0_f64.to_radians();
1309        let lon = (-122.0_f64).to_radians();
1310        let n = egm96_undulation(lat, lon);
1311        let h = 250.0;
1312        let big_h = egm96_orthometric_height_m(h, lat, lon);
1313        assert_eq!(big_h, h - n);
1314        assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
1315        // The egm96 path differs from the coarse path here (different model).
1316        assert_ne!(
1317            egm96_orthometric_height_m(h, lat, lon),
1318            orthometric_height_m(h, lat, lon)
1319        );
1320    }
1321
1322    #[test]
1323    fn batch_undulation_entries_match_scalar_lookup() {
1324        let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
1325        let got_deg = egm96_undulations_deg(&points_deg);
1326        let expected_deg: Vec<f64> = points_deg
1327            .iter()
1328            .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
1329            .collect();
1330        assert_eq!(got_deg, expected_deg);
1331
1332        let points_rad: Vec<(f64, f64)> = points_deg
1333            .iter()
1334            .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
1335            .collect();
1336        let got_rad = egm96_undulations_rad(&points_rad);
1337        let expected_rad: Vec<f64> = points_rad
1338            .iter()
1339            .map(|&(lat, lon)| egm96_undulation(lat, lon))
1340            .collect();
1341        assert_eq!(got_rad, expected_rad);
1342
1343        assert_eq!(
1344            geoid_undulations_deg(&points_deg),
1345            points_deg
1346                .iter()
1347                .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
1348                .collect::<Vec<_>>()
1349        );
1350    }
1351
1352    #[test]
1353    fn from_text_round_trips_a_grid() {
1354        let text = "\
1355# coarse 2x3 regional grid
1356# lat_min lon_min dlat dlon n_lat n_lon
135710.0 20.0 5.0 5.0 2 3
1358  1.0  2.0  3.0   # lat 10 row
1359  4.0  5.0  6.0   # lat 15 row
1360";
1361        let grid = GeoidGrid::from_text(text).expect("parse grid");
1362        assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
1363        assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
1364        // Cell center of the lower-left cell -> mean of the four corners.
1365        let center = grid.undulation_deg(12.5, 22.5);
1366        assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
1367        // A regional grid clamps rather than wraps outside its longitude span.
1368        assert_eq!(
1369            grid.undulation_deg(10.0, 0.0),
1370            grid.undulation_deg(10.0, 20.0)
1371        );
1372    }
1373
1374    #[test]
1375    fn from_text_rejects_short_data() {
1376        let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
1377        assert_eq!(
1378            GeoidGrid::from_text(text),
1379            Err(GeoidError::InvalidDimensions {
1380                expected: 4,
1381                found: 3
1382            })
1383        );
1384    }
1385
1386    #[test]
1387    fn from_egm2008_raster_window_decodes_little_and_big_endian_records() {
1388        let d = Egm2008GridSpacing::TwoPointFiveMinute.degrees();
1389        let window =
1390            Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 10.0, 20.0, 2, 3)
1391                .expect("EGM2008 test window");
1392        for little_endian in [true, false] {
1393            let bytes = egm2008_test_raster_bytes(window, little_endian, |src_row, col| {
1394                (src_row * 10 + col) as f32
1395            });
1396            let grid =
1397                GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008");
1398            assert_eq!(grid.undulation_deg(10.0, 20.0), 10.0);
1399            assert!((grid.undulation_deg(10.0 + d, 20.0 + 2.0 * d) - 2.0).abs() <= 1.0e-12);
1400            assert!((grid.undulation_deg(10.0 + 0.5 * d, 20.0 + d) - 6.0).abs() <= 1.0e-12);
1401        }
1402    }
1403
1404    #[test]
1405    fn from_egm2008_raster_rejects_bad_record_layout() {
1406        assert!(matches!(
1407            GeoidGrid::from_egm2008_raster(
1408                EGM2008_NORCAL_CROP_BYTES,
1409                Egm2008GridSpacing::TwoPointFiveMinute,
1410            ),
1411            Err(GeoidError::Parse { .. })
1412        ));
1413
1414        let window = egm2008_norcal_window();
1415        let mut bytes = EGM2008_NORCAL_CROP_BYTES.to_vec();
1416        bytes[0] = 0;
1417        assert!(matches!(
1418            GeoidGrid::from_egm2008_raster_window(&bytes, window),
1419            Err(GeoidError::Parse { .. })
1420        ));
1421    }
1422
1423    #[test]
1424    fn egm2008_crop_matches_proj_oracle() {
1425        let grid = GeoidGrid::from_egm2008_raster_window(
1426            EGM2008_NORCAL_CROP_BYTES,
1427            egm2008_norcal_window(),
1428        )
1429        .expect("parse EGM2008 crop");
1430        for fixture in PROJ_EGM2008_FIXTURES {
1431            let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1432            assert!(
1433                (got - fixture.undulation_m).abs() <= 0.005,
1434                "PROJ EGM2008 fixture ({}, {}): got {got}, want {}",
1435                fixture.lat_deg,
1436                fixture.lon_deg,
1437                fixture.undulation_m
1438            );
1439        }
1440    }
1441
1442    #[test]
1443    fn egm2008_regional_crop_clamps_grid_edges() {
1444        let grid = GeoidGrid::from_egm2008_raster_window(
1445            EGM2008_NORCAL_CROP_BYTES,
1446            egm2008_norcal_window(),
1447        )
1448        .expect("parse EGM2008 crop");
1449        assert_eq!(
1450            grid.undulation_deg(36.0, -124.0),
1451            grid.undulation_deg(37.0, -123.0)
1452        );
1453        assert_eq!(
1454            grid.undulation_deg(39.0, -121.0),
1455            grid.undulation_deg(38.0, -122.0)
1456        );
1457    }
1458
1459    #[test]
1460    fn egm2008_global_longitude_window_wraps_through_shared_kernel() {
1461        let spacing = Egm2008GridSpacing::TwoPointFiveMinute;
1462        let d = spacing.degrees();
1463        let (_, n_lon) = spacing.global_dimensions();
1464        let window = Egm2008RasterWindow::new(spacing, 0.0, 0.0, 2, n_lon)
1465            .expect("global-longitude EGM2008 window");
1466        let bytes = egm2008_test_raster_bytes(window, true, |_, col| {
1467            if col == 0 {
1468                100.0
1469            } else if col == n_lon - 1 {
1470                200.0
1471            } else {
1472                10.0
1473            }
1474        });
1475        let grid =
1476            GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008 wrap");
1477
1478        assert_eq!(grid.undulation_deg(0.0, 360.0), 100.0);
1479        assert_eq!(grid.undulation_deg(0.0, -0.5 * d), 150.0);
1480    }
1481
1482    #[test]
1483    fn new_rejects_bad_inputs() {
1484        assert!(matches!(
1485            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
1486            Err(GeoidError::InvalidDimensions { .. })
1487        ));
1488        assert!(matches!(
1489            GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
1490            Err(GeoidError::InvalidSpacing { field: "dlat" })
1491        ));
1492        assert!(matches!(
1493            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
1494            Err(GeoidError::NonFiniteValue { index: 1 })
1495        ));
1496    }
1497
1498    #[test]
1499    fn longitude_normalization_folds_into_half_open_interval() {
1500        assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1501        assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1502        assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1503        assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1504    }
1505
1506    /// The embedded EGM96 1-degree grid returns its genuine node values exactly
1507    /// at integer-degree positions (a node query is an exact bilinear hit). The
1508    /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
1509    /// transcribed from the source grid; see the provenance note in this module.
1510    #[test]
1511    fn egm96_grid_reproduces_genuine_nodes() {
1512        // (lat_deg, lon_deg, expected EGM96 undulation in metres).
1513        let nodes: [(f64, f64, f64); 5] = [
1514            (0.0, 0.0, 17.16),    // Gulf of Guinea
1515            (0.0, 80.0, -102.69), // Indian Ocean low
1516            (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
1517            (-90.0, 0.0, -29.53), // south pole
1518            (90.0, 0.0, 13.61),   // north pole
1519        ];
1520        for (lat, lon, want) in nodes {
1521            let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1522            assert!(
1523                (got - want).abs() <= 1.0e-9,
1524                "egm96 node ({lat},{lon}): got {got}, want {want}"
1525            );
1526        }
1527    }
1528
1529    /// The embedded EGM96 grid matches the independently published EGM96 geoid
1530    /// height at a known checkpoint within the tolerance set by its 1-degree
1531    /// resolution, and is far closer to truth than the coarse built-in.
1532    ///
1533    /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
1534    /// `16:46:33N 3:00:34W` (Timbuktu); see
1535    /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
1536    /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
1537    /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
1538    /// published value, well inside a 1-degree-resolution tolerance.
1539    #[test]
1540    fn egm96_grid_matches_published_checkpoint() {
1541        let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1542        let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1543        let published = 28.7068;
1544
1545        let egm96 = egm96_undulation(lat, lon);
1546        assert!(
1547            (egm96 - published).abs() < 0.5,
1548            "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1549        );
1550
1551        // The genuine 1-degree grid must be strictly closer to the published
1552        // value than the decametre-scale 30-degree built-in.
1553        let coarse = geoid_undulation(lat, lon);
1554        assert!(
1555            (egm96 - published).abs() < (coarse - published).abs(),
1556            "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1557        );
1558    }
1559
1560    #[test]
1561    fn egm96_embedded_outputs_are_bit_pinned() {
1562        let fixtures = [
1563            (37.0_f64, -122.0_f64, 0xc040_accc_cccc_cccdu64),
1564            (37.5_f64, -122.5_f64, 0xc040_de66_6666_6666u64),
1565            (
1566                16.0 + 46.0 / 60.0 + 33.0 / 3600.0,
1567                -(3.0 + 34.0 / 3600.0),
1568                0x403c_acb4_79a8_1af4u64,
1569            ),
1570            (0.125_f64, -179.875_f64, 0x4034_cbf5_c28f_5c29u64),
1571        ];
1572        for (lat_deg, lon_deg, bits) in fixtures {
1573            let got = egm96_undulation(lat_deg.to_radians(), lon_deg.to_radians());
1574            assert_eq!(
1575                got.to_bits(),
1576                bits,
1577                "EGM96 bit pin ({lat_deg}, {lon_deg}) got {got}"
1578            );
1579        }
1580    }
1581
1582    /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
1583    /// centimetres, north-to-south records flipped to latitude-ascending storage,
1584    /// longitude `0..359.75` E. Validated against an independently built grid of
1585    /// the same samples, plus the byte-length guard.
1586    #[test]
1587    fn from_egm96_dac_decodes_the_nga_layout() {
1588        let n_lat = super::EGM96_DAC_N_LAT;
1589        let n_lon = super::EGM96_DAC_N_LON;
1590        // A deterministic per-(record, column) pattern, well within int16 cm.
1591        let cm = |record: usize, col: usize| -> i16 {
1592            ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1593        };
1594
1595        let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1596        for record in 0..n_lat {
1597            for col in 0..n_lon {
1598                bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1599            }
1600        }
1601        let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1602
1603        // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
1604        // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
1605        let mut values_m = vec![0.0f64; n_lat * n_lon];
1606        for i in 0..n_lat {
1607            let record = n_lat - 1 - i;
1608            for col in 0..n_lon {
1609                values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1610            }
1611        }
1612        let expected =
1613            GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1614        assert_eq!(parsed, expected);
1615
1616        // A wrong byte length is rejected, not silently misread.
1617        assert!(matches!(
1618            GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1619            Err(GeoidError::Parse { .. })
1620        ));
1621    }
1622
1623    #[test]
1624    fn egm96_dac_sparse_fixture_matches_proj_oracle() {
1625        let bytes = sparse_egm96_dac_bytes();
1626        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1627        for fixture in PROJ_EGM96_FIXTURES {
1628            let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1629            assert!(
1630                (got - fixture.undulation_m).abs() <= 0.005,
1631                "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
1632                fixture.lat_deg,
1633                fixture.lon_deg,
1634                fixture.undulation_m
1635            );
1636        }
1637    }
1638
1639    #[test]
1640    fn geoid_grid_height_conversions_pin_sign_convention() {
1641        let bytes = sparse_egm96_dac_bytes();
1642        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1643        for fixture in PROJ_EGM96_FIXTURES {
1644            let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1645            let h = 250.0;
1646            let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
1647            assert_eq!(orthometric, h - n);
1648            assert_eq!(
1649                grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
1650                orthometric + n
1651            );
1652
1653            let lat_rad = fixture.lat_deg.to_radians();
1654            let lon_rad = fixture.lon_deg.to_radians();
1655            assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
1656            assert!(
1657                (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
1658                    .abs()
1659                    <= 1.0e-12
1660            );
1661        }
1662    }
1663}