sidereon_core/geoid.rs
1//! Geoid undulation (geoid height) lookup with bilinear interpolation.
2//!
3//! The geoid undulation `N` is the height of the geoid (mean sea level
4//! equipotential surface) above the WGS84 reference ellipsoid, in metres.
5//! GNSS positioning yields the ellipsoidal height `h`; the orthometric height
6//! `H` (height above mean sea level) is
7//!
8//! ```text
9//! H = h - N
10//! ```
11//!
12//! A geoid model is published as a regular latitude/longitude grid of `N`
13//! samples (EGM96, EGM2008, and the national models all ship this way). This
14//! module provides:
15//!
16//! - [`GeoidGrid`], a regular grid of undulation samples with bilinear
17//! interpolation ([`GeoidGrid::undulation_rad`] / [`GeoidGrid::undulation_deg`]);
18//! - [`GeoidGrid::from_text`], a data-loading hook that parses a simple,
19//! documented grid text format so a caller can supply a full EGM grid;
20//! - [`GeoidGrid::from_egm96_dac`], a loader for the authoritative NGA EGM96
21//! 15-arcminute binary grid (`WW15MGH.DAC`) for decimetre-class datum work;
22//! - [`egm96_undulation`] / [`egm96_grid`], a zero-setup lookup against an
23//! embedded genuine EGM96 1-degree global grid (a higher-accuracy alternative
24//! to the coarse built-in);
25//! - [`geoid_undulation`], a zero-setup lookup against a small COARSE built-in
26//! global grid, plus [`orthometric_height_m`] / [`ellipsoidal_height_m`] height
27//! conversion helpers.
28//!
29//! ## Choosing a grid
30//!
31//! Three accuracy tiers are available, in increasing fidelity:
32//!
33//! 1. [`geoid_undulation`] - the COARSE 30-degree built-in. It reproduces the
34//! large-scale character of the geoid (the Indian Ocean low, the North
35//! Atlantic / New Guinea highs, the polar offsets) and is fine for tests,
36//! sanity checks, and metre-scale fallback, but it is NOT survey-grade
37//! (decametre-level error).
38//! 2. [`egm96_undulation`] - an embedded GENUINE EGM96 1-degree global grid,
39//! decimated from the official NGA 15-arcminute model. Its bilinear lookup
40//! agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS (95th
41//! percentile ~0.7 m; up to a few metres over the steepest geoid gradients).
42//! This is the recommended zero-setup default for metre-class datum work.
43//! 3. [`GeoidGrid::from_egm96_dac`] with the official `WW15MGH.DAC` file (a
44//! ~2 MB download, not vendored here) - the full 15-arcminute resolution. Its
45//! bilinear lookup tracks the geoid to roughly decimetre RMS, but the
46//! worst-case bilinear interpolation error can still exceed 1 m over the
47//! steepest geoid gradients (see
48//! <https://geographiclib.sourceforge.io/html/geoid.html> for the egm96-15
49//! error envelope), so this path supports decimetre-class typical datum work
50//! rather than guaranteed sub-metre accuracy everywhere. Embedding the full
51//! grid is impractical (the 15-arcminute grid is ~1 M samples and EGM2008
52//! 1-minute is ~2.3 GB), so the high-resolution path loads the file at
53//! runtime.
54//!
55//! A caller with any other vendor grid can lower it to [`GeoidGrid::from_text`]
56//! or build a [`GeoidGrid`] via [`GeoidGrid::new`] and call
57//! [`GeoidGrid::undulation_rad`] directly.
58
59use std::sync::OnceLock;
60
61/// Why a geoid grid could not be constructed or parsed.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum GeoidError {
64 /// A grid dimension was zero, or the value count did not equal `n_lat * n_lon`.
65 InvalidDimensions {
66 /// What was expected.
67 expected: usize,
68 /// What was supplied.
69 found: usize,
70 },
71 /// A grid spacing or origin was non-finite or non-positive.
72 InvalidSpacing {
73 /// The offending field.
74 field: &'static str,
75 },
76 /// A grid sample value was non-finite.
77 NonFiniteValue {
78 /// Row-major index of the offending sample.
79 index: usize,
80 },
81 /// The grid text could not be parsed.
82 Parse {
83 /// A human-readable reason.
84 reason: String,
85 },
86}
87
88impl core::fmt::Display for GeoidError {
89 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90 match self {
91 Self::InvalidDimensions { expected, found } => {
92 write!(
93 f,
94 "geoid grid expected {expected} samples but found {found}"
95 )
96 }
97 Self::InvalidSpacing { field } => {
98 write!(f, "geoid grid {field} must be finite and positive")
99 }
100 Self::NonFiniteValue { index } => {
101 write!(f, "geoid grid sample {index} is not finite")
102 }
103 Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
104 }
105 }
106}
107
108impl std::error::Error for GeoidError {}
109
110/// A regular latitude/longitude grid of geoid undulation samples (metres) with
111/// bilinear interpolation.
112///
113/// Samples are stored row-major with latitude ascending (outer) and longitude
114/// ascending (inner): `values_m[i * n_lon + j]` is the undulation at latitude
115/// `lat_min_deg + i * dlat_deg` and longitude `lon_min_deg + j * dlon_deg`.
116///
117/// Latitude inputs are clamped to the grid's latitude span. Longitude inputs are
118/// normalized to `[-180, 180)` and then, when the grid spans a full 360 degrees
119/// of longitude, wrapped across the antimeridian; otherwise they are clamped to
120/// the grid's longitude span (so a regional grid does not wrap).
121#[derive(Debug, Clone, PartialEq)]
122pub struct GeoidGrid {
123 lat_min_deg: f64,
124 lon_min_deg: f64,
125 dlat_deg: f64,
126 dlon_deg: f64,
127 n_lat: usize,
128 n_lon: usize,
129 values_m: Vec<f64>,
130}
131
132impl GeoidGrid {
133 /// Build a geoid grid from its origin, spacing, dimensions, and row-major
134 /// samples (metres).
135 ///
136 /// Returns [`GeoidError`] when a dimension is zero, the sample count does not
137 /// equal `n_lat * n_lon`, a spacing/origin is non-finite or a spacing is
138 /// non-positive, or a sample is non-finite.
139 pub fn new(
140 lat_min_deg: f64,
141 lon_min_deg: f64,
142 dlat_deg: f64,
143 dlon_deg: f64,
144 n_lat: usize,
145 n_lon: usize,
146 values_m: Vec<f64>,
147 ) -> Result<Self, GeoidError> {
148 if n_lat == 0 || n_lon == 0 {
149 return Err(GeoidError::InvalidDimensions {
150 expected: 1,
151 found: 0,
152 });
153 }
154 let expected = n_lat * n_lon;
155 if values_m.len() != expected {
156 return Err(GeoidError::InvalidDimensions {
157 expected,
158 found: values_m.len(),
159 });
160 }
161 if !lat_min_deg.is_finite() {
162 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
163 }
164 if !lon_min_deg.is_finite() {
165 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
166 }
167 if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
168 return Err(GeoidError::InvalidSpacing { field: "dlat" });
169 }
170 if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
171 return Err(GeoidError::InvalidSpacing { field: "dlon" });
172 }
173 for (index, value) in values_m.iter().enumerate() {
174 if !value.is_finite() {
175 return Err(GeoidError::NonFiniteValue { index });
176 }
177 }
178 Ok(Self {
179 lat_min_deg,
180 lon_min_deg,
181 dlat_deg,
182 dlon_deg,
183 n_lat,
184 n_lon,
185 values_m,
186 })
187 }
188
189 /// Parse a geoid grid from a simple, documented text format (the data-loading
190 /// hook for full EGM grids).
191 ///
192 /// The format is whitespace-delimited with `#` line comments. The first
193 /// non-comment token sequence is a six-field header:
194 ///
195 /// ```text
196 /// lat_min lon_min dlat dlon n_lat n_lon
197 /// ```
198 ///
199 /// followed by exactly `n_lat * n_lon` undulation samples in metres, in
200 /// row-major order (latitude ascending outer, longitude ascending inner).
201 /// All angles are in degrees. This is deliberately a minimal, line-oriented
202 /// format; a caller converting a vendor grid (EGM `.gri`/`.ndp`, a GeoTIFF,
203 /// etc.) lowers it to this shape or builds a [`GeoidGrid`] via [`new`].
204 ///
205 /// [`new`]: GeoidGrid::new
206 pub fn from_text(text: &str) -> Result<Self, GeoidError> {
207 let mut tokens = text
208 .lines()
209 .map(|line| line.split('#').next().unwrap_or(""))
210 .flat_map(str::split_whitespace);
211
212 let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
213 let token = tokens.next().ok_or_else(|| GeoidError::Parse {
214 reason: format!("missing header field {field}"),
215 })?;
216 token.parse::<f64>().map_err(|_| GeoidError::Parse {
217 reason: format!("header field {field} is not a number: {token:?}"),
218 })
219 };
220
221 let lat_min_deg = next_field("lat_min")?;
222 let lon_min_deg = next_field("lon_min")?;
223 let dlat_deg = next_field("dlat")?;
224 let dlon_deg = next_field("dlon")?;
225 let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
226 let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
227
228 let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
229 reason: "n_lat * n_lon overflows".to_string(),
230 })?;
231 let mut values_m = Vec::with_capacity(expected);
232 for token in tokens {
233 let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
234 reason: format!("sample is not a number: {token:?}"),
235 })?;
236 values_m.push(value);
237 }
238
239 Self::new(
240 lat_min_deg,
241 lon_min_deg,
242 dlat_deg,
243 dlon_deg,
244 n_lat,
245 n_lon,
246 values_m,
247 )
248 }
249
250 /// Parse the authoritative NGA EGM96 15-arcminute binary geoid grid
251 /// (`WW15MGH.DAC`) for decimetre-class datum work.
252 ///
253 /// This is the highest-resolution path in the module. Its bilinear lookup
254 /// tracks the geoid to roughly decimetre RMS, but the worst-case bilinear
255 /// interpolation error can still exceed 1 m over the steepest geoid
256 /// gradients, so it does not guarantee sub-metre accuracy everywhere.
257 ///
258 /// The file is a headerless block of `721 * 1440` big-endian `INTEGER*2`
259 /// samples in centimetres, arranged north-to-south by record (record 1 at
260 /// latitude `+90`, last record at `-90`, in `0.25`-degree steps) and, within
261 /// each record, west-to-east by longitude from `0` to `359.75` degrees in
262 /// `0.25`-degree steps. Each sample is divided by 100 to get metres. The rows
263 /// are flipped to the latitude-ascending storage order of [`GeoidGrid`], so
264 /// the resulting grid is global in longitude and wraps across the
265 /// antimeridian like any other full-span grid.
266 ///
267 /// The file is not vendored in this crate (it is a ~2 MB public-domain NGA
268 /// download); fetch `WW15MGH.DAC` from the NGA EGM96 distribution and pass its
269 /// bytes here. For a zero-setup metre-class default without the download, use
270 /// [`egm96_undulation`] instead.
271 ///
272 /// Returns [`GeoidError::Parse`] if the byte length is not exactly
273 /// `721 * 1440 * 2` bytes.
274 pub fn from_egm96_dac(bytes: &[u8]) -> Result<Self, GeoidError> {
275 let expected = EGM96_DAC_N_LAT * EGM96_DAC_N_LON * 2;
276 if bytes.len() != expected {
277 return Err(GeoidError::Parse {
278 reason: format!(
279 "EGM96 WW15MGH.DAC must be {expected} bytes ({EGM96_DAC_N_LAT} x {EGM96_DAC_N_LON} big-endian int16), got {}",
280 bytes.len()
281 ),
282 });
283 }
284 let mut values_m = vec![0.0f64; EGM96_DAC_N_LAT * EGM96_DAC_N_LON];
285 for i in 0..EGM96_DAC_N_LAT {
286 // DAC record 0 is +90 (north); GeoidGrid stores latitude ascending,
287 // so internal row i (latitude -90 + i*0.25) reads DAC record N-1-i.
288 let src_row = EGM96_DAC_N_LAT - 1 - i;
289 for c in 0..EGM96_DAC_N_LON {
290 let off = (src_row * EGM96_DAC_N_LON + c) * 2;
291 let cm = i16::from_be_bytes([bytes[off], bytes[off + 1]]);
292 values_m[i * EGM96_DAC_N_LON + c] = f64::from(cm) / 100.0;
293 }
294 }
295 Self::new(
296 -90.0,
297 0.0,
298 0.25,
299 0.25,
300 EGM96_DAC_N_LAT,
301 EGM96_DAC_N_LON,
302 values_m,
303 )
304 }
305
306 /// Whether the grid spans a full 360 degrees of longitude (and therefore
307 /// wraps across the antimeridian during interpolation).
308 fn is_global_longitude(&self) -> bool {
309 ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
310 || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
311 }
312
313 /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
314 /// radians (latitude positive north, longitude positive east).
315 pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
316 self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
317 }
318
319 /// Batch bilinear undulation lookup for geodetic positions in radians.
320 ///
321 /// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
322 /// longitude positive east. Output element `i` is exactly the scalar
323 /// [`undulation_rad`](Self::undulation_rad) result for input element `i`.
324 pub fn undulations_rad(&self, points_rad: &[(f64, f64)]) -> Vec<f64> {
325 points_rad
326 .iter()
327 .map(|&(lat_rad, lon_rad)| self.undulation_rad(lat_rad, lon_rad))
328 .collect()
329 }
330
331 /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
332 /// degrees (latitude positive north, longitude positive east).
333 pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
334 let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
335 let (i0, i1, ty) = self.lat_bracket(lat);
336
337 let (j0, j1, tx) = self.lon_bracket(lon_deg);
338
339 let v00 = self.sample(i0, j0);
340 let v01 = self.sample(i0, j1);
341 let v10 = self.sample(i1, j0);
342 let v11 = self.sample(i1, j1);
343
344 let bottom = v00 + (v01 - v00) * tx;
345 let top = v10 + (v11 - v10) * tx;
346 bottom + (top - bottom) * ty
347 }
348
349 /// Batch bilinear undulation lookup for geodetic positions in degrees.
350 ///
351 /// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
352 /// longitude positive east. Output element `i` is exactly the scalar
353 /// [`undulation_deg`](Self::undulation_deg) result for input element `i`.
354 pub fn undulations_deg(&self, points_deg: &[(f64, f64)]) -> Vec<f64> {
355 points_deg
356 .iter()
357 .map(|&(lat_deg, lon_deg)| self.undulation_deg(lat_deg, lon_deg))
358 .collect()
359 }
360
361 /// Orthometric height `H = h - N` (metres above mean sea level) from an
362 /// ellipsoidal height and a geodetic position in radians, using this grid's
363 /// undulation.
364 pub fn orthometric_height_rad(
365 &self,
366 ellipsoidal_height_m: f64,
367 lat_rad: f64,
368 lon_rad: f64,
369 ) -> f64 {
370 ellipsoidal_height_m - self.undulation_rad(lat_rad, lon_rad)
371 }
372
373 /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
374 /// orthometric height and a geodetic position in radians, using this grid's
375 /// undulation.
376 pub fn ellipsoidal_height_rad(
377 &self,
378 orthometric_height_m: f64,
379 lat_rad: f64,
380 lon_rad: f64,
381 ) -> f64 {
382 orthometric_height_m + self.undulation_rad(lat_rad, lon_rad)
383 }
384
385 /// Orthometric height `H = h - N` (metres above mean sea level) from an
386 /// ellipsoidal height and a geodetic position in degrees, using this grid's
387 /// undulation.
388 pub fn orthometric_height_deg(
389 &self,
390 ellipsoidal_height_m: f64,
391 lat_deg: f64,
392 lon_deg: f64,
393 ) -> f64 {
394 ellipsoidal_height_m - self.undulation_deg(lat_deg, lon_deg)
395 }
396
397 /// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
398 /// orthometric height and a geodetic position in degrees, using this grid's
399 /// undulation.
400 pub fn ellipsoidal_height_deg(
401 &self,
402 orthometric_height_m: f64,
403 lat_deg: f64,
404 lon_deg: f64,
405 ) -> f64 {
406 orthometric_height_m + self.undulation_deg(lat_deg, lon_deg)
407 }
408
409 fn lat_max_deg(&self) -> f64 {
410 self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
411 }
412
413 /// Latitude bracketing cell indices and fractional position within the cell.
414 fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
415 if self.n_lat == 1 {
416 return (0, 0, 0.0);
417 }
418 let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
419 let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
420 let i0 = (pos.floor() as usize).min(self.n_lat - 2);
421 (i0, i0 + 1, pos - i0 as f64)
422 }
423
424 /// Longitude bracketing cell indices and fractional position within the cell.
425 /// Wraps across the antimeridian for a global grid; clamps for a regional one.
426 fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
427 if self.n_lon == 1 {
428 return (0, 0, 0.0);
429 }
430 let lon = normalize_longitude_deg(lon_deg);
431 if self.is_global_longitude() {
432 let span = self.n_lon as f64 * self.dlon_deg;
433 let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
434 // Guard the rare case where rounding lands offset exactly on span.
435 if offset >= span {
436 offset -= span;
437 }
438 let pos = offset / self.dlon_deg;
439 let j0 = (pos.floor() as usize) % self.n_lon;
440 let j1 = (j0 + 1) % self.n_lon;
441 (j0, j1, pos - pos.floor())
442 } else {
443 let pos =
444 ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
445 let j0 = (pos.floor() as usize).min(self.n_lon - 2);
446 (j0, j0 + 1, pos - j0 as f64)
447 }
448 }
449
450 fn sample(&self, i: usize, j: usize) -> f64 {
451 self.values_m[i * self.n_lon + j]
452 }
453}
454
455/// Parse a non-negative grid count from a float token.
456fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
457 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
458 return Err(GeoidError::Parse {
459 reason: format!("{field} must be a positive integer, got {value}"),
460 });
461 }
462 Ok(value as usize)
463}
464
465/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
466fn normalize_longitude_deg(lon_deg: f64) -> f64 {
467 let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
468 // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
469 if wrapped >= 180.0 {
470 wrapped - 360.0
471 } else {
472 wrapped
473 }
474}
475
476/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
477/// position in radians, from the COARSE built-in global grid.
478///
479/// Latitude is positive north, longitude positive east, both in radians. See
480/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
481/// load a real model with [`GeoidGrid::from_text`] and call
482/// [`GeoidGrid::undulation_rad`].
483pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
484 builtin_grid().undulation_rad(lat_rad, lon_rad)
485}
486
487/// Batch geoid undulation lookup against the COARSE built-in global grid.
488///
489/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
490/// longitude positive east. Output element `i` is exactly the scalar
491/// [`geoid_undulation`] result for input element `i`.
492pub fn geoid_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
493 builtin_grid().undulations_rad(points_rad)
494}
495
496/// Batch geoid undulation lookup against the COARSE built-in global grid.
497///
498/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
499/// longitude positive east.
500pub fn geoid_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
501 builtin_grid().undulations_deg(points_deg)
502}
503
504/// Orthometric height `H = h - N` (metres above mean sea level) from an
505/// ellipsoidal height and a geodetic position in radians, using the COARSE
506/// 30-degree built-in model's undulation (decametre-level error, NOT
507/// survey-grade). For metre-class conversion use [`egm96_orthometric_height_m`];
508/// for a real model, subtract [`GeoidGrid::undulation_rad`] directly.
509pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
510 ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
511}
512
513/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
514/// orthometric height and a geodetic position in radians, using the COARSE
515/// 30-degree built-in model's undulation (decametre-level error, NOT
516/// survey-grade). For metre-class conversion use [`egm96_ellipsoidal_height_m`];
517/// for a real model, add [`GeoidGrid::undulation_rad`] directly.
518pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
519 orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
520}
521
522/// Orthometric height `H = h - N` (metres above mean sea level) from an
523/// ellipsoidal height and a geodetic position in radians, using the embedded
524/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
525///
526/// This is the recommended zero-setup height converter for metre-class datum
527/// work; the [`orthometric_height_m`] sibling uses the COARSE 30-degree built-in
528/// instead and is only suitable for sanity checks.
529pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
530 ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
531}
532
533/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
534/// orthometric height and a geodetic position in radians, using the embedded
535/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
536///
537/// This is the recommended zero-setup height converter for metre-class datum
538/// work; the [`ellipsoidal_height_m`] sibling uses the COARSE 30-degree built-in
539/// instead and is only suitable for sanity checks.
540pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
541 orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
542}
543
544/// Latitude record count of the NGA EGM96 `WW15MGH.DAC` 15-arcminute grid.
545const EGM96_DAC_N_LAT: usize = 721;
546/// Longitude sample count per record of the NGA EGM96 `WW15MGH.DAC` grid.
547const EGM96_DAC_N_LON: usize = 1440;
548
549/// Latitude row count of the embedded genuine EGM96 1-degree grid.
550const EGM96_1DEG_N_LAT: usize = 181;
551/// Longitude column count of the embedded genuine EGM96 1-degree grid.
552const EGM96_1DEG_N_LON: usize = 360;
553
554// Provenance of the embedded EGM96 1-degree undulation grid
555// (`egm96_geoid_1deg.bin`):
556//
557// Source model: EGM96 (Earth Gravitational Model 1996), the joint NIMA (now
558// NGA) / NASA GSFC / Ohio State University global geopotential model. The geoid
559// undulation grid is a work of the U.S. Government and is in the public domain;
560// NGA distributes it without restriction. See THIRD-PARTY-NOTICES.md.
561//
562// Origin file: the official NGA 15-arcminute binary grid `WW15MGH.DAC`
563// (721 x 1440 big-endian INTEGER*2 centimetres, north-to-south records,
564// longitude 0..359.75 E), obtained from the public OpenSGeo PROJ vdatum mirror
565// (download.osgeo.org/proj/vdatum/egm96_15/). `egm96_geoid_1deg.bin` is that
566// grid decimated to a 1-degree lattice: each sample is the exact `WW15MGH.DAC`
567// value at the corresponding integer-degree node (no resampling or smoothing -
568// 1 degree is an integer multiple of the 0.25-degree source spacing), so every
569// value is a genuine EGM96 undulation, not a fabricated or fitted figure. The
570// packed format is 181 x 360 big-endian INTEGER*2 centimetres in
571// latitude-ascending (-90..+90), longitude-ascending (0..359 E) row-major order.
572// Decimating to 1 degree keeps the embedded data tractable (~127 KB) while its
573// bilinear lookup tracks the full 15-arcminute grid to ~0.4 m RMS.
574
575/// Bytes of the embedded genuine EGM96 1-degree undulation grid (big-endian
576/// int16 centimetres, latitude-ascending, longitude-ascending row-major).
577const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
578
579/// The embedded genuine EGM96 1-degree global geoid, decoded once on first use.
580///
581/// See [`egm96_undulation`] for the recommended scalar entry point and the
582/// module docs for the accuracy tiers.
583pub fn egm96_grid() -> &'static GeoidGrid {
584 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
585 GRID.get_or_init(|| {
586 assert_eq!(
587 EGM96_1DEG_BYTES.len(),
588 EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
589 "embedded EGM96 1-degree grid has the wrong byte length"
590 );
591 let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
592 for (k, value) in values_m.iter_mut().enumerate() {
593 let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
594 *value = f64::from(cm) / 100.0;
595 }
596 GeoidGrid::new(
597 -90.0,
598 0.0,
599 1.0,
600 1.0,
601 EGM96_1DEG_N_LAT,
602 EGM96_1DEG_N_LON,
603 values_m,
604 )
605 .expect("embedded EGM96 1-degree grid is well-formed")
606 })
607}
608
609/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
610/// position in radians, from the embedded GENUINE EGM96 1-degree global grid.
611///
612/// Latitude is positive north, longitude positive east, both in radians. This is
613/// the recommended zero-setup default for metre-class datum work: its bilinear
614/// lookup agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS. For the
615/// full-resolution model load the official `WW15MGH.DAC` via
616/// [`GeoidGrid::from_egm96_dac`]; for the lowest-fidelity legacy fallback use
617/// [`geoid_undulation`].
618pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
619 egm96_grid().undulation_rad(lat_rad, lon_rad)
620}
621
622/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
623/// global grid.
624///
625/// Each input tuple is `(lat_rad, lon_rad)`, with latitude positive north and
626/// longitude positive east. Output element `i` is exactly the scalar
627/// [`egm96_undulation`] result for input element `i`.
628pub fn egm96_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
629 egm96_grid().undulations_rad(points_rad)
630}
631
632/// Batch geoid undulation lookup against the embedded GENUINE EGM96 1-degree
633/// global grid.
634///
635/// Each input tuple is `(lat_deg, lon_deg)`, with latitude positive north and
636/// longitude positive east.
637pub fn egm96_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
638 egm96_grid().undulations_deg(points_deg)
639}
640
641/// The coarse 30-degree built-in global geoid, built once on first use.
642fn builtin_grid() -> &'static GeoidGrid {
643 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
644 GRID.get_or_init(|| {
645 GeoidGrid::new(
646 -90.0,
647 -180.0,
648 30.0,
649 30.0,
650 BUILTIN_N_LAT,
651 BUILTIN_N_LON,
652 BUILTIN_VALUES_M.to_vec(),
653 )
654 .expect("built-in geoid grid is well-formed")
655 })
656}
657
658const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
659const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
660
661/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
662/// ascending then longitude ascending. The values approximate the large-scale
663/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
664/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
665/// longitude columns coincide on the antimeridian so the global wrap is
666/// continuous.
667#[rustfmt::skip]
668const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
669 // lat = -90 (south pole)
670 -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0, -30.0,
671 // lat = -60
672 -15.0, -20.0, -25.0, -10.0, 5.0, 15.0, 20.0, 10.0, 0.0, -5.0, -10.0, -12.0, -15.0,
673 // lat = -30
674 20.0, 10.0, -5.0, -25.0, -15.0, 5.0, 25.0, 30.0, 20.0, 35.0, 40.0, 25.0, 20.0,
675 // lat = 0 (equator)
676 -10.0, -20.0, -15.0, -8.0, -5.0, 5.0, 17.0, 10.0, -30.0, -60.0, 30.0, 55.0, -10.0,
677 // lat = 30
678 5.0, 0.0, -15.0, -10.0, -40.0, 50.0, 45.0, 20.0, -25.0, -45.0, 0.0, 20.0, 5.0,
679 // lat = 60
680 0.0, -10.0, -20.0, -35.0, -20.0, 60.0, 45.0, 25.0, 10.0, -5.0, -15.0, -5.0, 0.0,
681 // lat = 90 (north pole)
682 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0, 13.0,
683];
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[derive(Clone, Copy)]
690 struct ProjGeoidFixture {
691 lat_deg: f64,
692 lon_deg: f64,
693 undulation_m: f64,
694 }
695
696 // PROJ oracle provenance for the 15-arcminute EGM96 fixture below:
697 //
698 // Tool: PROJ 9.8.1 (`cct`, Rel. 9.8.1, April 10th, 2026).
699 // Grid: `us_nga_egm96_15.tif`, fetched with
700 // `projsync --target-dir /tmp/sidereon-proj-egm96 --file us_nga_egm96_15.tif`.
701 // Grid SHA-256:
702 // db493027562c9b004d7220fa881f5603adada4e1c5029b933fa7de4547b0e78d.
703 // Command:
704 // `PROJ_DATA=/tmp/sidereon-proj-egm96 cct -d 12 +proj=pipeline +step +inv
705 // +proj=vgridshift +grids=us_nga_egm96_15.tif +multiplier=1`.
706 //
707 // `cct` returns orthometric height for an ellipsoidal-height input. With
708 // input height 0, the geoid undulation is `-output_z`.
709 const PROJ_EGM96_FIXTURES: &[ProjGeoidFixture] = &[
710 ProjGeoidFixture {
711 lat_deg: 0.000000,
712 lon_deg: 0.000000,
713 undulation_m: 17.161579132080,
714 },
715 ProjGeoidFixture {
716 lat_deg: 0.000000,
717 lon_deg: 80.000000,
718 undulation_m: -102.687904357910,
719 },
720 ProjGeoidFixture {
721 lat_deg: 60.000000,
722 lon_deg: -30.000000,
723 undulation_m: 63.799266815186,
724 },
725 ProjGeoidFixture {
726 lat_deg: 45.625000,
727 lon_deg: 12.375000,
728 undulation_m: 44.181870460510,
729 },
730 ProjGeoidFixture {
731 lat_deg: 0.125000,
732 lon_deg: 179.875000,
733 undulation_m: 21.099070549011,
734 },
735 ProjGeoidFixture {
736 lat_deg: 0.125000,
737 lon_deg: -179.875000,
738 undulation_m: 20.864660263062,
739 },
740 ProjGeoidFixture {
741 lat_deg: -10.500000,
742 lon_deg: 179.990000,
743 undulation_m: 38.607539978027,
744 },
745 ProjGeoidFixture {
746 lat_deg: -10.500000,
747 lon_deg: -179.990000,
748 undulation_m: 38.540365447998,
749 },
750 ProjGeoidFixture {
751 lat_deg: 89.875000,
752 lon_deg: 45.000000,
753 undulation_m: 13.639517307281,
754 },
755 ProjGeoidFixture {
756 lat_deg: -89.875000,
757 lon_deg: 123.625000,
758 undulation_m: -29.676423549652,
759 },
760 ProjGeoidFixture {
761 lat_deg: 37.774900,
762 lon_deg: -122.419400,
763 undulation_m: -32.242452185586,
764 },
765 ];
766
767 // Real EGM96 15-arcminute node values, rounded to the centimetre grid the
768 // NGA `WW15MGH.DAC` format stores. The sparse test grid writes only these
769 // nodes into an otherwise-zero DAC-sized byte buffer; each oracle point
770 // above falls in a cell whose four corners are present here. This avoids
771 // committing the full 2 MB grid while still checking node registration,
772 // antimeridian wrap, pole-row handling, and bilinear cell selection against
773 // PROJ-derived values. The largest measured PROJ-vs-DAC-centimetre
774 // difference in these fixtures is 0.0032 m.
775 const SPARSE_EGM96_DAC_NODES_CM: &[(f64, f64, i16)] = &[
776 (-90.00, 123.50, -2953),
777 (-90.00, 123.75, -2953),
778 (-89.75, 123.50, -2982),
779 (-89.75, 123.75, -2982),
780 (-10.50, 179.75, 3919),
781 (-10.50, 180.00, 3858),
782 (-10.50, 180.25, 3751),
783 (-10.25, 179.75, 3733),
784 (-10.25, 180.00, 3697),
785 (-10.25, 180.25, 3611),
786 (0.00, 0.00, 1716),
787 (0.00, 0.25, 1708),
788 (0.00, 80.00, -10269),
789 (0.00, 80.25, -10255),
790 (0.00, 179.75, 2138),
791 (0.00, 180.00, 2115),
792 (0.00, 180.25, 2095),
793 (0.25, 0.00, 1719),
794 (0.25, 0.25, 1711),
795 (0.25, 80.00, -10286),
796 (0.25, 80.25, -10276),
797 (0.25, 179.75, 2109),
798 (0.25, 180.00, 2078),
799 (0.25, 180.25, 2058),
800 (37.75, 237.50, -3237),
801 (37.75, 237.75, -3204),
802 (38.00, 237.50, -3211),
803 (38.00, 237.75, -3200),
804 (45.50, 12.25, 4398),
805 (45.50, 12.50, 4355),
806 (45.75, 12.25, 4498),
807 (45.75, 12.50, 4421),
808 (60.00, 330.00, 6380),
809 (60.00, 330.25, 6400),
810 (60.25, 330.00, 6365),
811 (60.25, 330.25, 6388),
812 (89.75, 45.00, 1367),
813 (89.75, 45.25, 1367),
814 (90.00, 45.00, 1361),
815 (90.00, 45.25, 1361),
816 ];
817
818 fn sparse_egm96_dac_bytes() -> Vec<u8> {
819 let mut bytes = vec![0u8; super::EGM96_DAC_N_LAT * super::EGM96_DAC_N_LON * 2];
820 for &(lat_deg, lon_east_deg, cm) in SPARSE_EGM96_DAC_NODES_CM {
821 let record = ((90.0 - lat_deg) / 0.25).round() as usize;
822 let col = (lon_east_deg.rem_euclid(360.0) / 0.25).round() as usize;
823 assert!(record < super::EGM96_DAC_N_LAT);
824 assert!(col < super::EGM96_DAC_N_LON);
825 let off = (record * super::EGM96_DAC_N_LON + col) * 2;
826 bytes[off..off + 2].copy_from_slice(&cm.to_be_bytes());
827 }
828 bytes
829 }
830
831 #[test]
832 fn builtin_returns_exact_node_values() {
833 // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
834 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
835 // (lat 0, lon 90 deg) is the Indian Ocean low node.
836 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
837 // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
838 assert_eq!(
839 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
840 60.0
841 );
842 }
843
844 #[test]
845 fn builtin_captures_major_geoid_features_by_sign() {
846 // The Indian Ocean is the global geoid low: undulation is strongly negative.
847 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
848 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
849 // The North Atlantic is a geoid high: undulation is positive.
850 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
851 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
852 }
853
854 #[test]
855 fn bilinear_midpoint_is_the_corner_average() {
856 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
857 // Cell-center: equal weight to all four corners -> their mean.
858 let center = grid.undulation_deg(5.0, 5.0);
859 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
860 // Edge midpoints interpolate along one axis only.
861 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
862 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
863 // Corners return the node values exactly.
864 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
865 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
866 }
867
868 #[test]
869 fn global_grid_wraps_across_the_antimeridian() {
870 // A global grid whose +180 column equals its -180 column interpolates
871 // continuously across the seam: two points a hair either side of the
872 // antimeridian return nearly the same undulation (no discontinuity).
873 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
874 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
875 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
876 // The antimeridian node itself is -10 m on the equator row.
877 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
878 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
879 // Exactly +180 and -180 are the same physical meridian -> same value.
880 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
881 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
882 assert_eq!(plus, minus);
883 assert_eq!(plus, -10.0);
884 }
885
886 #[test]
887 fn orthometric_height_subtracts_undulation() {
888 let lat = 0.0;
889 let lon = 0.0;
890 let n = geoid_undulation(lat, lon);
891 assert_eq!(n, 17.0);
892 // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
893 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
894 // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
895 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
896 }
897
898 #[test]
899 fn egm96_height_converters_use_the_egm96_undulation() {
900 // A known point well away from the coarse-grid agreement; the egm96
901 // converters must subtract/add the genuine EGM96 1-degree undulation, not
902 // the coarse 30-degree built-in.
903 let lat = 37.0_f64.to_radians();
904 let lon = (-122.0_f64).to_radians();
905 let n = egm96_undulation(lat, lon);
906 let h = 250.0;
907 let big_h = egm96_orthometric_height_m(h, lat, lon);
908 assert_eq!(big_h, h - n);
909 assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
910 // The egm96 path differs from the coarse path here (different model).
911 assert_ne!(
912 egm96_orthometric_height_m(h, lat, lon),
913 orthometric_height_m(h, lat, lon)
914 );
915 }
916
917 #[test]
918 fn batch_undulation_entries_match_scalar_lookup() {
919 let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
920 let got_deg = egm96_undulations_deg(&points_deg);
921 let expected_deg: Vec<f64> = points_deg
922 .iter()
923 .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
924 .collect();
925 assert_eq!(got_deg, expected_deg);
926
927 let points_rad: Vec<(f64, f64)> = points_deg
928 .iter()
929 .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
930 .collect();
931 let got_rad = egm96_undulations_rad(&points_rad);
932 let expected_rad: Vec<f64> = points_rad
933 .iter()
934 .map(|&(lat, lon)| egm96_undulation(lat, lon))
935 .collect();
936 assert_eq!(got_rad, expected_rad);
937
938 assert_eq!(
939 geoid_undulations_deg(&points_deg),
940 points_deg
941 .iter()
942 .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
943 .collect::<Vec<_>>()
944 );
945 }
946
947 #[test]
948 fn from_text_round_trips_a_grid() {
949 let text = "\
950# coarse 2x3 regional grid
951# lat_min lon_min dlat dlon n_lat n_lon
95210.0 20.0 5.0 5.0 2 3
953 1.0 2.0 3.0 # lat 10 row
954 4.0 5.0 6.0 # lat 15 row
955";
956 let grid = GeoidGrid::from_text(text).expect("parse grid");
957 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
958 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
959 // Cell center of the lower-left cell -> mean of the four corners.
960 let center = grid.undulation_deg(12.5, 22.5);
961 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
962 // A regional grid clamps rather than wraps outside its longitude span.
963 assert_eq!(
964 grid.undulation_deg(10.0, 0.0),
965 grid.undulation_deg(10.0, 20.0)
966 );
967 }
968
969 #[test]
970 fn from_text_rejects_short_data() {
971 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
972 assert_eq!(
973 GeoidGrid::from_text(text),
974 Err(GeoidError::InvalidDimensions {
975 expected: 4,
976 found: 3
977 })
978 );
979 }
980
981 #[test]
982 fn new_rejects_bad_inputs() {
983 assert!(matches!(
984 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
985 Err(GeoidError::InvalidDimensions { .. })
986 ));
987 assert!(matches!(
988 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
989 Err(GeoidError::InvalidSpacing { field: "dlat" })
990 ));
991 assert!(matches!(
992 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
993 Err(GeoidError::NonFiniteValue { index: 1 })
994 ));
995 }
996
997 #[test]
998 fn longitude_normalization_folds_into_half_open_interval() {
999 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1000 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1001 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1002 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1003 }
1004
1005 /// The embedded EGM96 1-degree grid returns its genuine node values exactly
1006 /// at integer-degree positions (a node query is an exact bilinear hit). The
1007 /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
1008 /// transcribed from the source grid; see the provenance note in this module.
1009 #[test]
1010 fn egm96_grid_reproduces_genuine_nodes() {
1011 // (lat_deg, lon_deg, expected EGM96 undulation in metres).
1012 let nodes: [(f64, f64, f64); 5] = [
1013 (0.0, 0.0, 17.16), // Gulf of Guinea
1014 (0.0, 80.0, -102.69), // Indian Ocean low
1015 (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
1016 (-90.0, 0.0, -29.53), // south pole
1017 (90.0, 0.0, 13.61), // north pole
1018 ];
1019 for (lat, lon, want) in nodes {
1020 let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1021 assert!(
1022 (got - want).abs() <= 1.0e-9,
1023 "egm96 node ({lat},{lon}): got {got}, want {want}"
1024 );
1025 }
1026 }
1027
1028 /// The embedded EGM96 grid matches the independently published EGM96 geoid
1029 /// height at a known checkpoint within the tolerance set by its 1-degree
1030 /// resolution, and is far closer to truth than the coarse built-in.
1031 ///
1032 /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
1033 /// `16:46:33N 3:00:34W` (Timbuktu); see
1034 /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
1035 /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
1036 /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
1037 /// published value, well inside a 1-degree-resolution tolerance.
1038 #[test]
1039 fn egm96_grid_matches_published_checkpoint() {
1040 let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1041 let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1042 let published = 28.7068;
1043
1044 let egm96 = egm96_undulation(lat, lon);
1045 assert!(
1046 (egm96 - published).abs() < 0.5,
1047 "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1048 );
1049
1050 // The genuine 1-degree grid must be strictly closer to the published
1051 // value than the decametre-scale 30-degree built-in.
1052 let coarse = geoid_undulation(lat, lon);
1053 assert!(
1054 (egm96 - published).abs() < (coarse - published).abs(),
1055 "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1056 );
1057 }
1058
1059 /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
1060 /// centimetres, north-to-south records flipped to latitude-ascending storage,
1061 /// longitude `0..359.75` E. Validated against an independently built grid of
1062 /// the same samples, plus the byte-length guard.
1063 #[test]
1064 fn from_egm96_dac_decodes_the_nga_layout() {
1065 let n_lat = super::EGM96_DAC_N_LAT;
1066 let n_lon = super::EGM96_DAC_N_LON;
1067 // A deterministic per-(record, column) pattern, well within int16 cm.
1068 let cm = |record: usize, col: usize| -> i16 {
1069 ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1070 };
1071
1072 let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1073 for record in 0..n_lat {
1074 for col in 0..n_lon {
1075 bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1076 }
1077 }
1078 let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1079
1080 // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
1081 // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
1082 let mut values_m = vec![0.0f64; n_lat * n_lon];
1083 for i in 0..n_lat {
1084 let record = n_lat - 1 - i;
1085 for col in 0..n_lon {
1086 values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1087 }
1088 }
1089 let expected =
1090 GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1091 assert_eq!(parsed, expected);
1092
1093 // A wrong byte length is rejected, not silently misread.
1094 assert!(matches!(
1095 GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1096 Err(GeoidError::Parse { .. })
1097 ));
1098 }
1099
1100 #[test]
1101 fn egm96_dac_sparse_fixture_matches_proj_oracle() {
1102 let bytes = sparse_egm96_dac_bytes();
1103 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1104 for fixture in PROJ_EGM96_FIXTURES {
1105 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1106 assert!(
1107 (got - fixture.undulation_m).abs() <= 0.005,
1108 "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
1109 fixture.lat_deg,
1110 fixture.lon_deg,
1111 fixture.undulation_m
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn geoid_grid_height_conversions_pin_sign_convention() {
1118 let bytes = sparse_egm96_dac_bytes();
1119 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1120 for fixture in PROJ_EGM96_FIXTURES {
1121 let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1122 let h = 250.0;
1123 let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
1124 assert_eq!(orthometric, h - n);
1125 assert_eq!(
1126 grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
1127 orthometric + n
1128 );
1129
1130 let lat_rad = fixture.lat_deg.to_radians();
1131 let lon_rad = fixture.lon_deg.to_radians();
1132 assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
1133 assert!(
1134 (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
1135 .abs()
1136 <= 1.0e-12
1137 );
1138 }
1139 }
1140}