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..].chunks_exact(4) {
558            let value = f32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
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].chunks_exact(8) {
1559            let index = u32::from_be_bytes(record[..4].try_into().expect("fixture node index"));
1560            let offset = super::PROJ_EGM96_GTX_HEADER_BYTES + index as usize * 4;
1561            gtx[offset..offset + 4].copy_from_slice(&record[4..8]);
1562        }
1563        (gtx, point_offset, point_count)
1564    }
1565
1566    #[test]
1567    fn builtin_returns_exact_node_values() {
1568        // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
1569        assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
1570        // (lat 0, lon 90 deg) is the Indian Ocean low node.
1571        assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
1572        // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
1573        assert_eq!(
1574            geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
1575            60.0
1576        );
1577    }
1578
1579    #[test]
1580    fn builtin_captures_major_geoid_features_by_sign() {
1581        // The Indian Ocean is the global geoid low: undulation is strongly negative.
1582        let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
1583        assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
1584        // The North Atlantic is a geoid high: undulation is positive.
1585        let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
1586        assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
1587    }
1588
1589    #[test]
1590    fn bilinear_midpoint_is_the_corner_average() {
1591        let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
1592        // Cell-center: equal weight to all four corners -> their mean.
1593        let center = grid.undulation_deg(5.0, 5.0);
1594        assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
1595        // Edge midpoints interpolate along one axis only.
1596        assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
1597        assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
1598        // Corners return the node values exactly.
1599        assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
1600        assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
1601    }
1602
1603    #[test]
1604    fn global_grid_wraps_across_the_antimeridian() {
1605        // A global grid whose +180 column equals its -180 column interpolates
1606        // continuously across the seam: two points a hair either side of the
1607        // antimeridian return nearly the same undulation (no discontinuity).
1608        let east = geoid_undulation(0.0, 179.999_f64.to_radians());
1609        let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
1610        assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
1611        // The antimeridian node itself is -10 m on the equator row.
1612        assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
1613        assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
1614        // Exactly +180 and -180 are the same physical meridian -> same value.
1615        let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
1616        let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
1617        assert_eq!(plus, minus);
1618        assert_eq!(plus, -10.0);
1619    }
1620
1621    #[test]
1622    fn orthometric_height_subtracts_undulation() {
1623        let lat = 0.0;
1624        let lon = 0.0;
1625        let n = geoid_undulation(lat, lon);
1626        assert_eq!(n, 17.0);
1627        // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
1628        assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
1629        // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
1630        assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
1631    }
1632
1633    #[test]
1634    fn egm96_height_converters_use_the_egm96_undulation() {
1635        // A known point well away from the coarse-grid agreement; the egm96
1636        // converters must subtract/add the genuine EGM96 1-degree undulation, not
1637        // the coarse 30-degree built-in.
1638        let lat = 37.0_f64.to_radians();
1639        let lon = (-122.0_f64).to_radians();
1640        let n = egm96_undulation(lat, lon);
1641        let h = 250.0;
1642        let big_h = egm96_orthometric_height_m(h, lat, lon);
1643        assert_eq!(big_h, h - n);
1644        assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
1645        // The egm96 path differs from the coarse path here (different model).
1646        assert_ne!(
1647            egm96_orthometric_height_m(h, lat, lon),
1648            orthometric_height_m(h, lat, lon)
1649        );
1650    }
1651
1652    #[test]
1653    fn batch_undulation_entries_match_scalar_lookup() {
1654        let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
1655        let got_deg = egm96_undulations_deg(&points_deg);
1656        let expected_deg: Vec<f64> = points_deg
1657            .iter()
1658            .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
1659            .collect();
1660        assert_eq!(got_deg, expected_deg);
1661
1662        let points_rad: Vec<(f64, f64)> = points_deg
1663            .iter()
1664            .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
1665            .collect();
1666        let got_rad = egm96_undulations_rad(&points_rad);
1667        let expected_rad: Vec<f64> = points_rad
1668            .iter()
1669            .map(|&(lat, lon)| egm96_undulation(lat, lon))
1670            .collect();
1671        assert_eq!(got_rad, expected_rad);
1672
1673        assert_eq!(
1674            geoid_undulations_deg(&points_deg),
1675            points_deg
1676                .iter()
1677                .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
1678                .collect::<Vec<_>>()
1679        );
1680    }
1681
1682    #[test]
1683    fn from_text_round_trips_a_grid() {
1684        let text = "\
1685# coarse 2x3 regional grid
1686# lat_min lon_min dlat dlon n_lat n_lon
168710.0 20.0 5.0 5.0 2 3
1688  1.0  2.0  3.0   # lat 10 row
1689  4.0  5.0  6.0   # lat 15 row
1690";
1691        let grid = GeoidGrid::from_text(text).expect("parse grid");
1692        assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
1693        assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
1694        // Cell center of the lower-left cell -> mean of the four corners.
1695        let center = grid.undulation_deg(12.5, 22.5);
1696        assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
1697        // A regional grid clamps rather than wraps outside its longitude span.
1698        assert_eq!(
1699            grid.undulation_deg(10.0, 0.0),
1700            grid.undulation_deg(10.0, 20.0)
1701        );
1702    }
1703
1704    #[test]
1705    fn from_text_rejects_short_data() {
1706        let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
1707        assert_eq!(
1708            GeoidGrid::from_text(text),
1709            Err(GeoidError::InvalidDimensions {
1710                expected: 4,
1711                found: 3
1712            })
1713        );
1714    }
1715
1716    #[test]
1717    fn from_egm2008_raster_window_decodes_little_and_big_endian_records() {
1718        let d = Egm2008GridSpacing::TwoPointFiveMinute.degrees();
1719        let window =
1720            Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 10.0, 20.0, 2, 3)
1721                .expect("EGM2008 test window");
1722        for little_endian in [true, false] {
1723            let bytes = egm2008_test_raster_bytes(window, little_endian, |src_row, col| {
1724                (src_row * 10 + col) as f32
1725            });
1726            let grid =
1727                GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008");
1728            assert_eq!(grid.undulation_deg(10.0, 20.0), 10.0);
1729            assert!((grid.undulation_deg(10.0 + d, 20.0 + 2.0 * d) - 2.0).abs() <= 1.0e-12);
1730            assert!((grid.undulation_deg(10.0 + 0.5 * d, 20.0 + d) - 6.0).abs() <= 1.0e-12);
1731        }
1732    }
1733
1734    #[test]
1735    fn from_egm2008_raster_rejects_bad_record_layout() {
1736        assert!(matches!(
1737            GeoidGrid::from_egm2008_raster(
1738                EGM2008_NORCAL_CROP_BYTES,
1739                Egm2008GridSpacing::TwoPointFiveMinute,
1740            ),
1741            Err(GeoidError::Parse { .. })
1742        ));
1743
1744        let window = egm2008_norcal_window();
1745        let mut bytes = EGM2008_NORCAL_CROP_BYTES.to_vec();
1746        bytes[0] = 0;
1747        assert!(matches!(
1748            GeoidGrid::from_egm2008_raster_window(&bytes, window),
1749            Err(GeoidError::Parse { .. })
1750        ));
1751    }
1752
1753    #[test]
1754    fn egm2008_crop_matches_proj_oracle() {
1755        let grid = GeoidGrid::from_egm2008_raster_window(
1756            EGM2008_NORCAL_CROP_BYTES,
1757            egm2008_norcal_window(),
1758        )
1759        .expect("parse EGM2008 crop");
1760        for fixture in PROJ_EGM2008_FIXTURES {
1761            let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1762            assert!(
1763                (got - fixture.undulation_m).abs() <= 0.005,
1764                "PROJ EGM2008 fixture ({}, {}): got {got}, want {}",
1765                fixture.lat_deg,
1766                fixture.lon_deg,
1767                fixture.undulation_m
1768            );
1769        }
1770    }
1771
1772    #[test]
1773    fn egm2008_regional_crop_clamps_grid_edges() {
1774        let grid = GeoidGrid::from_egm2008_raster_window(
1775            EGM2008_NORCAL_CROP_BYTES,
1776            egm2008_norcal_window(),
1777        )
1778        .expect("parse EGM2008 crop");
1779        assert_eq!(
1780            grid.undulation_deg(36.0, -124.0),
1781            grid.undulation_deg(37.0, -123.0)
1782        );
1783        assert_eq!(
1784            grid.undulation_deg(39.0, -121.0),
1785            grid.undulation_deg(38.0, -122.0)
1786        );
1787    }
1788
1789    #[test]
1790    fn egm2008_global_longitude_window_wraps_through_shared_kernel() {
1791        let spacing = Egm2008GridSpacing::TwoPointFiveMinute;
1792        let d = spacing.degrees();
1793        let (_, n_lon) = spacing.global_dimensions();
1794        let window = Egm2008RasterWindow::new(spacing, 0.0, 0.0, 2, n_lon)
1795            .expect("global-longitude EGM2008 window");
1796        let bytes = egm2008_test_raster_bytes(window, true, |_, col| {
1797            if col == 0 {
1798                100.0
1799            } else if col == n_lon - 1 {
1800                200.0
1801            } else {
1802                10.0
1803            }
1804        });
1805        let grid =
1806            GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008 wrap");
1807
1808        assert_eq!(grid.undulation_deg(0.0, 360.0), 100.0);
1809        assert_eq!(grid.undulation_deg(0.0, -0.5 * d), 150.0);
1810    }
1811
1812    #[test]
1813    fn new_rejects_bad_inputs() {
1814        assert!(matches!(
1815            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
1816            Err(GeoidError::InvalidDimensions { .. })
1817        ));
1818        assert!(matches!(
1819            GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
1820            Err(GeoidError::InvalidSpacing { field: "dlat" })
1821        ));
1822        assert!(matches!(
1823            GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
1824            Err(GeoidError::NonFiniteValue { index: 1 })
1825        ));
1826    }
1827
1828    #[test]
1829    fn longitude_normalization_folds_into_half_open_interval() {
1830        assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1831        assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1832        assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1833        assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1834    }
1835
1836    /// The embedded EGM96 1-degree grid returns its genuine node values exactly
1837    /// at integer-degree positions (a node query is an exact bilinear hit). The
1838    /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
1839    /// transcribed from the source grid; see the provenance note in this module.
1840    #[test]
1841    fn egm96_grid_reproduces_genuine_nodes() {
1842        // (lat_deg, lon_deg, expected EGM96 undulation in metres).
1843        let nodes: [(f64, f64, f64); 5] = [
1844            (0.0, 0.0, 17.16),    // Gulf of Guinea
1845            (0.0, 80.0, -102.69), // Indian Ocean low
1846            (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
1847            (-90.0, 0.0, -29.53), // south pole
1848            (90.0, 0.0, 13.61),   // north pole
1849        ];
1850        for (lat, lon, want) in nodes {
1851            let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1852            assert!(
1853                (got - want).abs() <= 1.0e-9,
1854                "egm96 node ({lat},{lon}): got {got}, want {want}"
1855            );
1856        }
1857    }
1858
1859    /// The embedded EGM96 grid matches the independently published EGM96 geoid
1860    /// height at a known checkpoint within the tolerance set by its 1-degree
1861    /// resolution, and is far closer to truth than the coarse built-in.
1862    ///
1863    /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
1864    /// `16:46:33N 3:00:34W` (Timbuktu); see
1865    /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
1866    /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
1867    /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
1868    /// published value, well inside a 1-degree-resolution tolerance.
1869    #[test]
1870    fn egm96_grid_matches_published_checkpoint() {
1871        let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1872        let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1873        let published = 28.7068;
1874
1875        let egm96 = egm96_undulation(lat, lon);
1876        assert!(
1877            (egm96 - published).abs() < 0.5,
1878            "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1879        );
1880
1881        // The genuine 1-degree grid must be strictly closer to the published
1882        // value than the decametre-scale 30-degree built-in.
1883        let coarse = geoid_undulation(lat, lon);
1884        assert!(
1885            (egm96 - published).abs() < (coarse - published).abs(),
1886            "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1887        );
1888    }
1889
1890    #[test]
1891    fn egm96_embedded_outputs_are_bit_pinned() {
1892        let fixtures = [
1893            (37.0_f64, -122.0_f64, 0xc040_accc_cccc_cccdu64),
1894            (37.5_f64, -122.5_f64, 0xc040_de66_6666_6666u64),
1895            (
1896                16.0 + 46.0 / 60.0 + 33.0 / 3600.0,
1897                -(3.0 + 34.0 / 3600.0),
1898                0x403c_acb4_79a8_1af4u64,
1899            ),
1900            (0.125_f64, -179.875_f64, 0x4034_cbf5_c28f_5c29u64),
1901        ];
1902        for (lat_deg, lon_deg, bits) in fixtures {
1903            let got = egm96_undulation(lat_deg.to_radians(), lon_deg.to_radians());
1904            assert_eq!(
1905                got.to_bits(),
1906                bits,
1907                "EGM96 bit pin ({lat_deg}, {lon_deg}) got {got}"
1908            );
1909        }
1910    }
1911
1912    /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
1913    /// centimetres, north-to-south records flipped to latitude-ascending storage,
1914    /// longitude `0..359.75` E. Validated against an independently built grid of
1915    /// the same samples, plus the byte-length guard.
1916    #[test]
1917    fn from_egm96_dac_decodes_the_nga_layout() {
1918        let n_lat = super::EGM96_DAC_N_LAT;
1919        let n_lon = super::EGM96_DAC_N_LON;
1920        // A deterministic per-(record, column) pattern, well within int16 cm.
1921        let cm = |record: usize, col: usize| -> i16 {
1922            ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1923        };
1924
1925        let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1926        for record in 0..n_lat {
1927            for col in 0..n_lon {
1928                bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1929            }
1930        }
1931        let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1932
1933        // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
1934        // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
1935        let mut values_m = vec![0.0f64; n_lat * n_lon];
1936        for i in 0..n_lat {
1937            let record = n_lat - 1 - i;
1938            for col in 0..n_lon {
1939                values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1940            }
1941        }
1942        let expected =
1943            GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1944        assert_eq!(parsed, expected);
1945
1946        // A wrong byte length is rejected, not silently misread.
1947        assert!(matches!(
1948            GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1949            Err(GeoidError::Parse { .. })
1950        ));
1951    }
1952
1953    #[test]
1954    fn from_proj_egm96_gtx_rejects_wrong_layout_and_nonfinite_samples() {
1955        let (mut bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
1956        assert!(matches!(
1957            GeoidGrid::from_proj_egm96_gtx(&bytes[..bytes.len() - 4]),
1958            Err(GeoidError::Parse { .. })
1959        ));
1960
1961        bytes[16..24].copy_from_slice(&0.5f64.to_be_bytes());
1962        assert!(matches!(
1963            GeoidGrid::from_proj_egm96_gtx(&bytes),
1964            Err(GeoidError::Parse { .. })
1965        ));
1966
1967        bytes[16..24].copy_from_slice(&0.25f64.to_be_bytes());
1968        bytes[super::PROJ_EGM96_GTX_HEADER_BYTES..super::PROJ_EGM96_GTX_HEADER_BYTES + 4]
1969            .copy_from_slice(&f32::NAN.to_be_bytes());
1970        assert_eq!(
1971            GeoidGrid::from_proj_egm96_gtx(&bytes),
1972            Err(GeoidError::NonFiniteValue { index: 0 })
1973        );
1974    }
1975
1976    #[test]
1977    fn proj_930_egm96_fused_dense_sample_is_zero_ulp() {
1978        let (bytes, point_offset, point_count) = sparse_proj_egm96_gtx_from_dense_fixture();
1979        let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
1980
1981        for (index, record) in PROJ_EGM96_930_DENSE_BYTES[point_offset..]
1982            .chunks_exact(24)
1983            .enumerate()
1984        {
1985            let lon_rad = f64::from_bits(u64::from_be_bytes(
1986                record[..8].try_into().expect("fixture longitude"),
1987            ));
1988            let lat_rad = f64::from_bits(u64::from_be_bytes(
1989                record[8..16].try_into().expect("fixture latitude"),
1990            ));
1991            let expected_bits =
1992                u64::from_be_bytes(record[16..24].try_into().expect("fixture undulation"));
1993            let got = grid
1994                .undulation_proj_rad(lat_rad, lon_rad, ProjVgridshiftArithmetic::FusedMultiplyAdd)
1995                .expect("fixture coordinate is inside the grid");
1996            assert_eq!(
1997                got.to_bits(),
1998                expected_bits,
1999                "contracted PROJ 9.3.0 EGM96 point {index}/{point_count}: lat={lat_rad}, lon={lon_rad}, got={got}"
2000            );
2001        }
2002        assert_eq!(point_count, 13_051);
2003    }
2004
2005    #[test]
2006    fn proj_930_egm96_separate_multiply_add_bits_are_pinned() {
2007        let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2008        let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2009
2010        // These cases are spread across the dense fixture and differ by one ULP
2011        // from contracted PROJ builds. Expected values come from the reviewed
2012        // PROJ 9.3.0 source sequence with contraction disabled.
2013        let cases = [
2014            (
2015                0xbff9_21e9_072f_0bff,
2016                0x3fb4_1b28_2494_7d44,
2017                0xc03d_88b2_3abe_f2f0,
2018            ),
2019            (
2020                0xbfd9_21e9_072f_0bfc,
2021                0x4006_eef9_c9b9_5ed9,
2022                0x4049_ff99_e8a7_6f47,
2023            ),
2024            (
2025                0x3fd9_21e9_072f_0c01,
2026                0x4004_6b94_c526_cf33,
2027                0x403d_018d_8044_d991,
2028            ),
2029            (
2030                0x3fef_6a63_48fa_cf01,
2031                0x3ffb_a557_324c_2c33,
2032                0xc044_4b8e_0e1f_a00c,
2033            ),
2034            (
2035                0x3ff9_21e9_072f_0bff,
2036                0x4009_21f2_2db9_9c8b,
2037                0x402b_362a_4459_31d0,
2038            ),
2039        ];
2040        for (lat_bits, lon_bits, expected_bits) in cases {
2041            let got = grid
2042                .undulation_proj_rad(
2043                    f64::from_bits(lat_bits),
2044                    f64::from_bits(lon_bits),
2045                    ProjVgridshiftArithmetic::SeparateMultiplyAdd,
2046                )
2047                .expect("fixture coordinate is inside the grid");
2048            assert_eq!(got.to_bits(), expected_bits);
2049        }
2050    }
2051
2052    #[test]
2053    fn proj_lookup_rejects_invalid_coordinates_without_panicking() {
2054        let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2055        let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2056        let arithmetic = ProjVgridshiftArithmetic::FusedMultiplyAdd;
2057
2058        assert_eq!(
2059            grid.undulation_proj_rad(f64::NAN, 0.0, arithmetic),
2060            Err(ProjVgridshiftError::NonFiniteCoordinate { field: "latitude" })
2061        );
2062        assert_eq!(
2063            grid.undulation_proj_rad(0.0, f64::INFINITY, arithmetic),
2064            Err(ProjVgridshiftError::NonFiniteCoordinate { field: "longitude" })
2065        );
2066        assert_eq!(
2067            grid.undulation_proj_rad(-91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2068            Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2069        );
2070        assert_eq!(
2071            grid.undulation_proj_rad(91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2072            Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2073        );
2074        assert!(grid
2075            .undulation_proj_rad(0.0, f64::MAX, arithmetic)
2076            .expect("full-world grids wrap every finite longitude")
2077            .is_finite());
2078    }
2079
2080    #[test]
2081    fn egm96_dac_sparse_fixture_matches_proj_oracle() {
2082        let bytes = sparse_egm96_dac_bytes();
2083        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2084        for fixture in PROJ_EGM96_FIXTURES {
2085            let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2086            assert!(
2087                (got - fixture.undulation_m).abs() <= 0.005,
2088                "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
2089                fixture.lat_deg,
2090                fixture.lon_deg,
2091                fixture.undulation_m
2092            );
2093        }
2094    }
2095
2096    #[test]
2097    fn geoid_grid_height_conversions_pin_sign_convention() {
2098        let bytes = sparse_egm96_dac_bytes();
2099        let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2100        for fixture in PROJ_EGM96_FIXTURES {
2101            let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2102            let h = 250.0;
2103            let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
2104            assert_eq!(orthometric, h - n);
2105            assert_eq!(
2106                grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
2107                orthometric + n
2108            );
2109
2110            let lat_rad = fixture.lat_deg.to_radians();
2111            let lon_rad = fixture.lon_deg.to_radians();
2112            assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
2113            assert!(
2114                (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
2115                    .abs()
2116                    <= 1.0e-12
2117            );
2118        }
2119    }
2120}