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