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 /// Bilinearly interpolated undulation `N` (metres) at a geodetic position in
320 /// degrees (latitude positive north, longitude positive east).
321 pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
322 let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
323 let (i0, i1, ty) = self.lat_bracket(lat);
324
325 let (j0, j1, tx) = self.lon_bracket(lon_deg);
326
327 let v00 = self.sample(i0, j0);
328 let v01 = self.sample(i0, j1);
329 let v10 = self.sample(i1, j0);
330 let v11 = self.sample(i1, j1);
331
332 let bottom = v00 + (v01 - v00) * tx;
333 let top = v10 + (v11 - v10) * tx;
334 bottom + (top - bottom) * ty
335 }
336
337 fn lat_max_deg(&self) -> f64 {
338 self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
339 }
340
341 /// Latitude bracketing cell indices and fractional position within the cell.
342 fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
343 if self.n_lat == 1 {
344 return (0, 0, 0.0);
345 }
346 let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
347 let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
348 let i0 = (pos.floor() as usize).min(self.n_lat - 2);
349 (i0, i0 + 1, pos - i0 as f64)
350 }
351
352 /// Longitude bracketing cell indices and fractional position within the cell.
353 /// Wraps across the antimeridian for a global grid; clamps for a regional one.
354 fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
355 if self.n_lon == 1 {
356 return (0, 0, 0.0);
357 }
358 let lon = normalize_longitude_deg(lon_deg);
359 if self.is_global_longitude() {
360 let span = self.n_lon as f64 * self.dlon_deg;
361 let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
362 // Guard the rare case where rounding lands offset exactly on span.
363 if offset >= span {
364 offset -= span;
365 }
366 let pos = offset / self.dlon_deg;
367 let j0 = (pos.floor() as usize) % self.n_lon;
368 let j1 = (j0 + 1) % self.n_lon;
369 (j0, j1, pos - pos.floor())
370 } else {
371 let pos =
372 ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
373 let j0 = (pos.floor() as usize).min(self.n_lon - 2);
374 (j0, j0 + 1, pos - j0 as f64)
375 }
376 }
377
378 fn sample(&self, i: usize, j: usize) -> f64 {
379 self.values_m[i * self.n_lon + j]
380 }
381}
382
383/// Parse a non-negative grid count from a float token.
384fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
385 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
386 return Err(GeoidError::Parse {
387 reason: format!("{field} must be a positive integer, got {value}"),
388 });
389 }
390 Ok(value as usize)
391}
392
393/// Normalize a longitude in degrees to the half-open interval `[-180, 180)`.
394fn normalize_longitude_deg(lon_deg: f64) -> f64 {
395 let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
396 // rem_euclid can yield +180.0 for inputs at the boundary; fold it to -180.0.
397 if wrapped >= 180.0 {
398 wrapped - 360.0
399 } else {
400 wrapped
401 }
402}
403
404/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
405/// position in radians, from the COARSE built-in global grid.
406///
407/// Latitude is positive north, longitude positive east, both in radians. See
408/// the module docs for the built-in-grid-vs-real-model trade-off: for accuracy
409/// load a real model with [`GeoidGrid::from_text`] and call
410/// [`GeoidGrid::undulation_rad`].
411pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
412 builtin_grid().undulation_rad(lat_rad, lon_rad)
413}
414
415/// Orthometric height `H = h - N` (metres above mean sea level) from an
416/// ellipsoidal height and a geodetic position in radians, using the COARSE
417/// 30-degree built-in model's undulation (decametre-level error, NOT
418/// survey-grade). For metre-class conversion use [`egm96_orthometric_height_m`];
419/// for a real model, subtract [`GeoidGrid::undulation_rad`] directly.
420pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
421 ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
422}
423
424/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
425/// orthometric height and a geodetic position in radians, using the COARSE
426/// 30-degree built-in model's undulation (decametre-level error, NOT
427/// survey-grade). For metre-class conversion use [`egm96_ellipsoidal_height_m`];
428/// for a real model, add [`GeoidGrid::undulation_rad`] directly.
429pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
430 orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
431}
432
433/// Orthometric height `H = h - N` (metres above mean sea level) from an
434/// ellipsoidal height and a geodetic position in radians, using the embedded
435/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
436///
437/// This is the recommended zero-setup height converter for metre-class datum
438/// work; the [`orthometric_height_m`] sibling uses the COARSE 30-degree built-in
439/// instead and is only suitable for sanity checks.
440pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
441 ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
442}
443
444/// Ellipsoidal height `h = H + N` (metres above the WGS84 ellipsoid) from an
445/// orthometric height and a geodetic position in radians, using the embedded
446/// GENUINE EGM96 1-degree model via [`egm96_undulation`].
447///
448/// This is the recommended zero-setup height converter for metre-class datum
449/// work; the [`ellipsoidal_height_m`] sibling uses the COARSE 30-degree built-in
450/// instead and is only suitable for sanity checks.
451pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
452 orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
453}
454
455/// Latitude record count of the NGA EGM96 `WW15MGH.DAC` 15-arcminute grid.
456const EGM96_DAC_N_LAT: usize = 721;
457/// Longitude sample count per record of the NGA EGM96 `WW15MGH.DAC` grid.
458const EGM96_DAC_N_LON: usize = 1440;
459
460/// Latitude row count of the embedded genuine EGM96 1-degree grid.
461const EGM96_1DEG_N_LAT: usize = 181;
462/// Longitude column count of the embedded genuine EGM96 1-degree grid.
463const EGM96_1DEG_N_LON: usize = 360;
464
465// Provenance of the embedded EGM96 1-degree undulation grid
466// (`egm96_geoid_1deg.bin`):
467//
468// Source model: EGM96 (Earth Gravitational Model 1996), the joint NIMA (now
469// NGA) / NASA GSFC / Ohio State University global geopotential model. The geoid
470// undulation grid is a work of the U.S. Government and is in the public domain;
471// NGA distributes it without restriction. See THIRD-PARTY-NOTICES.md.
472//
473// Origin file: the official NGA 15-arcminute binary grid `WW15MGH.DAC`
474// (721 x 1440 big-endian INTEGER*2 centimetres, north-to-south records,
475// longitude 0..359.75 E), obtained from the public OpenSGeo PROJ vdatum mirror
476// (download.osgeo.org/proj/vdatum/egm96_15/). `egm96_geoid_1deg.bin` is that
477// grid decimated to a 1-degree lattice: each sample is the exact `WW15MGH.DAC`
478// value at the corresponding integer-degree node (no resampling or smoothing -
479// 1 degree is an integer multiple of the 0.25-degree source spacing), so every
480// value is a genuine EGM96 undulation, not a fabricated or fitted figure. The
481// packed format is 181 x 360 big-endian INTEGER*2 centimetres in
482// latitude-ascending (-90..+90), longitude-ascending (0..359 E) row-major order.
483// Decimating to 1 degree keeps the embedded data tractable (~127 KB) while its
484// bilinear lookup tracks the full 15-arcminute grid to ~0.4 m RMS.
485
486/// Bytes of the embedded genuine EGM96 1-degree undulation grid (big-endian
487/// int16 centimetres, latitude-ascending, longitude-ascending row-major).
488const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
489
490/// The embedded genuine EGM96 1-degree global geoid, decoded once on first use.
491///
492/// See [`egm96_undulation`] for the recommended scalar entry point and the
493/// module docs for the accuracy tiers.
494pub fn egm96_grid() -> &'static GeoidGrid {
495 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
496 GRID.get_or_init(|| {
497 assert_eq!(
498 EGM96_1DEG_BYTES.len(),
499 EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
500 "embedded EGM96 1-degree grid has the wrong byte length"
501 );
502 let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
503 for (k, value) in values_m.iter_mut().enumerate() {
504 let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
505 *value = f64::from(cm) / 100.0;
506 }
507 GeoidGrid::new(
508 -90.0,
509 0.0,
510 1.0,
511 1.0,
512 EGM96_1DEG_N_LAT,
513 EGM96_1DEG_N_LON,
514 values_m,
515 )
516 .expect("embedded EGM96 1-degree grid is well-formed")
517 })
518}
519
520/// Geoid undulation `N` (metres above the WGS84 ellipsoid) at a geodetic
521/// position in radians, from the embedded GENUINE EGM96 1-degree global grid.
522///
523/// Latitude is positive north, longitude positive east, both in radians. This is
524/// the recommended zero-setup default for metre-class datum work: its bilinear
525/// lookup agrees with the full 15-arcminute EGM96 grid to ~0.4 m RMS. For the
526/// full-resolution model load the official `WW15MGH.DAC` via
527/// [`GeoidGrid::from_egm96_dac`]; for the lowest-fidelity legacy fallback use
528/// [`geoid_undulation`].
529pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
530 egm96_grid().undulation_rad(lat_rad, lon_rad)
531}
532
533/// The coarse 30-degree built-in global geoid, built once on first use.
534fn builtin_grid() -> &'static GeoidGrid {
535 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
536 GRID.get_or_init(|| {
537 GeoidGrid::new(
538 -90.0,
539 -180.0,
540 30.0,
541 30.0,
542 BUILTIN_N_LAT,
543 BUILTIN_N_LON,
544 BUILTIN_VALUES_M.to_vec(),
545 )
546 .expect("built-in geoid grid is well-formed")
547 })
548}
549
550const BUILTIN_N_LAT: usize = 7; // latitudes -90, -60, -30, 0, 30, 60, 90
551const BUILTIN_N_LON: usize = 13; // longitudes -180 .. 180 step 30 (col 0 == col 12)
552
553/// A COARSE 30-degree global geoid undulation field (metres). Row-major, latitude
554/// ascending then longitude ascending. The values approximate the large-scale
555/// EGM character (Gulf of Guinea / North Atlantic / New Guinea highs, the Indian
556/// Ocean low, polar offsets); they are NOT survey-grade. The first and last
557/// longitude columns coincide on the antimeridian so the global wrap is
558/// continuous.
559#[rustfmt::skip]
560const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
561 // lat = -90 (south pole)
562 -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,
563 // lat = -60
564 -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,
565 // lat = -30
566 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,
567 // lat = 0 (equator)
568 -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,
569 // lat = 30
570 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,
571 // lat = 60
572 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,
573 // lat = 90 (north pole)
574 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,
575];
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn builtin_returns_exact_node_values() {
583 // (lat 0, lon 0) is the Gulf of Guinea node, a documented +17 m sample.
584 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
585 // (lat 0, lon 90 deg) is the Indian Ocean low node.
586 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
587 // (lat 60 N, lon -30 deg) is the North Atlantic / Iceland high node.
588 assert_eq!(
589 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
590 60.0
591 );
592 }
593
594 #[test]
595 fn builtin_captures_major_geoid_features_by_sign() {
596 // The Indian Ocean is the global geoid low: undulation is strongly negative.
597 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
598 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
599 // The North Atlantic is a geoid high: undulation is positive.
600 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
601 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
602 }
603
604 #[test]
605 fn bilinear_midpoint_is_the_corner_average() {
606 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
607 // Cell-center: equal weight to all four corners -> their mean.
608 let center = grid.undulation_deg(5.0, 5.0);
609 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
610 // Edge midpoints interpolate along one axis only.
611 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
612 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
613 // Corners return the node values exactly.
614 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
615 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
616 }
617
618 #[test]
619 fn global_grid_wraps_across_the_antimeridian() {
620 // A global grid whose +180 column equals its -180 column interpolates
621 // continuously across the seam: two points a hair either side of the
622 // antimeridian return nearly the same undulation (no discontinuity).
623 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
624 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
625 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
626 // The antimeridian node itself is -10 m on the equator row.
627 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
628 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
629 // Exactly +180 and -180 are the same physical meridian -> same value.
630 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
631 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
632 assert_eq!(plus, minus);
633 assert_eq!(plus, -10.0);
634 }
635
636 #[test]
637 fn orthometric_height_subtracts_undulation() {
638 let lat = 0.0;
639 let lon = 0.0;
640 let n = geoid_undulation(lat, lon);
641 assert_eq!(n, 17.0);
642 // h = 117 m ellipsoidal -> H = 117 - 17 = 100 m above mean sea level.
643 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
644 // H = 100 m orthometric -> h = 100 + 17 = 117 m ellipsoidal.
645 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
646 }
647
648 #[test]
649 fn egm96_height_converters_use_the_egm96_undulation() {
650 // A known point well away from the coarse-grid agreement; the egm96
651 // converters must subtract/add the genuine EGM96 1-degree undulation, not
652 // the coarse 30-degree built-in.
653 let lat = 37.0_f64.to_radians();
654 let lon = (-122.0_f64).to_radians();
655 let n = egm96_undulation(lat, lon);
656 let h = 250.0;
657 let big_h = egm96_orthometric_height_m(h, lat, lon);
658 assert_eq!(big_h, h - n);
659 assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
660 // The egm96 path differs from the coarse path here (different model).
661 assert_ne!(
662 egm96_orthometric_height_m(h, lat, lon),
663 orthometric_height_m(h, lat, lon)
664 );
665 }
666
667 #[test]
668 fn from_text_round_trips_a_grid() {
669 let text = "\
670# coarse 2x3 regional grid
671# lat_min lon_min dlat dlon n_lat n_lon
67210.0 20.0 5.0 5.0 2 3
673 1.0 2.0 3.0 # lat 10 row
674 4.0 5.0 6.0 # lat 15 row
675";
676 let grid = GeoidGrid::from_text(text).expect("parse grid");
677 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
678 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
679 // Cell center of the lower-left cell -> mean of the four corners.
680 let center = grid.undulation_deg(12.5, 22.5);
681 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
682 // A regional grid clamps rather than wraps outside its longitude span.
683 assert_eq!(
684 grid.undulation_deg(10.0, 0.0),
685 grid.undulation_deg(10.0, 20.0)
686 );
687 }
688
689 #[test]
690 fn from_text_rejects_short_data() {
691 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
692 assert_eq!(
693 GeoidGrid::from_text(text),
694 Err(GeoidError::InvalidDimensions {
695 expected: 4,
696 found: 3
697 })
698 );
699 }
700
701 #[test]
702 fn new_rejects_bad_inputs() {
703 assert!(matches!(
704 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
705 Err(GeoidError::InvalidDimensions { .. })
706 ));
707 assert!(matches!(
708 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
709 Err(GeoidError::InvalidSpacing { field: "dlat" })
710 ));
711 assert!(matches!(
712 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
713 Err(GeoidError::NonFiniteValue { index: 1 })
714 ));
715 }
716
717 #[test]
718 fn longitude_normalization_folds_into_half_open_interval() {
719 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
720 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
721 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
722 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
723 }
724
725 /// The embedded EGM96 1-degree grid returns its genuine node values exactly
726 /// at integer-degree positions (a node query is an exact bilinear hit). The
727 /// expected figures are the corresponding `WW15MGH.DAC` samples (cm/100),
728 /// transcribed from the source grid; see the provenance note in this module.
729 #[test]
730 fn egm96_grid_reproduces_genuine_nodes() {
731 // (lat_deg, lon_deg, expected EGM96 undulation in metres).
732 let nodes: [(f64, f64, f64); 5] = [
733 (0.0, 0.0, 17.16), // Gulf of Guinea
734 (0.0, 80.0, -102.69), // Indian Ocean low
735 (60.0, -30.0, 63.80), // North Atlantic high (lon -30 == 330 E)
736 (-90.0, 0.0, -29.53), // south pole
737 (90.0, 0.0, 13.61), // north pole
738 ];
739 for (lat, lon, want) in nodes {
740 let got = egm96_undulation(lat.to_radians(), lon.to_radians());
741 assert!(
742 (got - want).abs() <= 1.0e-9,
743 "egm96 node ({lat},{lon}): got {got}, want {want}"
744 );
745 }
746 }
747
748 /// The embedded EGM96 grid matches the independently published EGM96 geoid
749 /// height at a known checkpoint within the tolerance set by its 1-degree
750 /// resolution, and is far closer to truth than the coarse built-in.
751 ///
752 /// Reference: GeographicLib `GeoidEval` (egm96-5) reports `28.7068` m at
753 /// `16:46:33N 3:00:34W` (Timbuktu); see
754 /// `https://geographiclib.sourceforge.io/C++/doc/GeoidEval.1.html`. The full
755 /// 15-arcminute EGM96 grid bilinearly interpolates to `28.6976` m there; the
756 /// embedded 1-degree grid lands at `28.6746` m, i.e. within ~0.03 m of the
757 /// published value, well inside a 1-degree-resolution tolerance.
758 #[test]
759 fn egm96_grid_matches_published_checkpoint() {
760 let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
761 let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
762 let published = 28.7068;
763
764 let egm96 = egm96_undulation(lat, lon);
765 assert!(
766 (egm96 - published).abs() < 0.5,
767 "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
768 );
769
770 // The genuine 1-degree grid must be strictly closer to the published
771 // value than the decametre-scale 30-degree built-in.
772 let coarse = geoid_undulation(lat, lon);
773 assert!(
774 (egm96 - published).abs() < (coarse - published).abs(),
775 "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
776 );
777 }
778
779 /// `from_egm96_dac` decodes the NGA `WW15MGH.DAC` layout: big-endian int16
780 /// centimetres, north-to-south records flipped to latitude-ascending storage,
781 /// longitude `0..359.75` E. Validated against an independently built grid of
782 /// the same samples, plus the byte-length guard.
783 #[test]
784 fn from_egm96_dac_decodes_the_nga_layout() {
785 let n_lat = super::EGM96_DAC_N_LAT;
786 let n_lon = super::EGM96_DAC_N_LON;
787 // A deterministic per-(record, column) pattern, well within int16 cm.
788 let cm = |record: usize, col: usize| -> i16 {
789 ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
790 };
791
792 let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
793 for record in 0..n_lat {
794 for col in 0..n_lon {
795 bytes.extend_from_slice(&cm(record, col).to_be_bytes());
796 }
797 }
798 let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
799
800 // Independent reconstruction: internal row i (latitude -90 + i*0.25) is
801 // DAC record n_lat-1-i, columns unchanged, centimetres -> metres.
802 let mut values_m = vec![0.0f64; n_lat * n_lon];
803 for i in 0..n_lat {
804 let record = n_lat - 1 - i;
805 for col in 0..n_lon {
806 values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
807 }
808 }
809 let expected =
810 GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
811 assert_eq!(parsed, expected);
812
813 // A wrong byte length is rejected, not silently misread.
814 assert!(matches!(
815 GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
816 Err(GeoidError::Parse { .. })
817 ));
818 }
819}