oxigdal_proj/geoid.rs
1//! Geoid model loading + bilinear-interpolation undulation queries for vertical
2//! CRS transformations.
3//!
4//! A *geoid* is the equipotential surface of Earth's gravity field that best
5//! approximates mean sea level. Orthometric heights are measured relative to
6//! the geoid; ellipsoidal heights (what GNSS receivers report) are measured
7//! relative to a reference ellipsoid (e.g. WGS84). The two are related by the
8//! geoid undulation `N`:
9//!
10//! ```text
11//! h_ellipsoidal = h_orthometric + N
12//! ```
13//!
14//! This module loads NGA-format little-endian f32 geoid grids (EGM96 and
15//! EGM2008 are the most widely deployed models) and performs bilinear
16//! interpolation to recover `N` at arbitrary (lat, lon) queries. Longitude
17//! queries wrap modulo 360°; latitude queries clamp to the grid extent.
18//!
19//! The crate ships with a **synthetic** geoid generator
20//! ([`synthetic_grid`]) for testing and for use in environments where a real
21//! grid file is not available. Production code should load a vendor grid
22//! (EGM96, EGM2008) via [`load_egm_grid`].
23//!
24//! # Examples
25//!
26//! ```
27//! use oxigdal_proj::geoid::{GeoidModel, synthetic_grid};
28//!
29//! let grid = synthetic_grid(GeoidModel::Egm96);
30//! let undulation_m = grid.geoid_height_m(35.0, 139.0); // Tokyo (degrees)
31//! let h_ortho = 50.0_f64;
32//! let h_ellip = grid.orthometric_to_ellipsoidal(35.0, 139.0, h_ortho);
33//! assert!((h_ellip - h_ortho - undulation_m).abs() < 1e-9);
34//! ```
35
36#[cfg(feature = "std")]
37use std::path::Path;
38
39use crate::error::Error;
40
41#[cfg(not(feature = "std"))]
42use alloc::format;
43#[cfg(not(feature = "std"))]
44use alloc::vec::Vec;
45
46// ---------------------------------------------------------------------------
47// Model identifier
48// ---------------------------------------------------------------------------
49
50/// Identifier for a known global geoid model.
51///
52/// EGM96 (15'×15' grid, ~5 cm RMS over land) and EGM2008 (1'×1' or 2.5'×2.5'
53/// grids, ~1 cm RMS over land) are the most widely used global models.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum GeoidModel {
56 /// Earth Gravitational Model 1996 (NGA / NASA).
57 Egm96,
58 /// Earth Gravitational Model 2008 (NGA).
59 Egm2008,
60}
61
62impl GeoidModel {
63 /// Returns the canonical short display name (`"EGM96"`, `"EGM2008"`).
64 pub fn display_name(&self) -> &'static str {
65 match self {
66 Self::Egm96 => "EGM96",
67 Self::Egm2008 => "EGM2008",
68 }
69 }
70
71 /// Returns a reasonable default grid spacing in **degrees**.
72 ///
73 /// Both models support multiple grid resolutions; this returns a coarse
74 /// value (0.25° = 15') suitable for synthetic-grid tests and for sizing
75 /// expectations when no metadata is provided.
76 pub fn default_grid_spacing_deg(&self) -> f64 {
77 match self {
78 Self::Egm96 => 0.25,
79 Self::Egm2008 => 0.25,
80 }
81 }
82
83 /// Returns the approximate accuracy of the model over land (centimetres).
84 pub fn approx_accuracy_cm(&self) -> f64 {
85 match self {
86 Self::Egm96 => 50.0,
87 Self::Egm2008 => 10.0,
88 }
89 }
90}
91
92impl core::fmt::Display for GeoidModel {
93 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
94 f.write_str(self.display_name())
95 }
96}
97
98// ---------------------------------------------------------------------------
99// Grid container
100// ---------------------------------------------------------------------------
101
102/// A geoid undulation grid (north-to-south or south-to-north depending on
103/// `lat_step_deg` sign; this container is agnostic and only requires
104/// `lat_step_deg > 0` for the south-to-north convention used by
105/// [`synthetic_grid`] and [`GeoidGrid::geoid_height_m`]).
106///
107/// Heights are stored in **metres**, row-major, indexed as
108/// `heights_m[lat_idx * n_lon + lon_idx]`. Latitude index `0` corresponds to
109/// `lat_min_deg`; longitude index `0` corresponds to `lon_min_deg`.
110#[derive(Debug, Clone)]
111pub struct GeoidGrid {
112 /// Source model identifier.
113 pub model: GeoidModel,
114 /// Latitude step between adjacent rows (degrees, positive ⇒ northward).
115 pub lat_step_deg: f64,
116 /// Longitude step between adjacent columns (degrees, positive ⇒ eastward).
117 pub lon_step_deg: f64,
118 /// Southernmost latitude of the grid (degrees).
119 pub lat_min_deg: f64,
120 /// Western longitude origin of the grid (degrees).
121 pub lon_min_deg: f64,
122 /// Number of latitude rows.
123 pub n_lat: usize,
124 /// Number of longitude columns.
125 pub n_lon: usize,
126 /// Row-major heights in metres, indexed by `[lat_idx * n_lon + lon_idx]`.
127 pub heights_m: Vec<f32>,
128}
129
130// ---------------------------------------------------------------------------
131// Synthetic grid (for tests and offline use)
132// ---------------------------------------------------------------------------
133
134/// Builds a synthetic 2°×2° global geoid grid using an analytic formula.
135///
136/// The height at `(lat, lon)` is computed as `30.0 * sin(lat) * cos(lon)`
137/// (metres), which produces a smooth, plausible-looking but **non-physical**
138/// undulation field suitable for round-trip testing.
139///
140/// The returned grid spans `lat ∈ [-90, 90]`, `lon ∈ [-180, 178]` (the
141/// 180°-meridian wrap is handled by [`GeoidGrid::geoid_height_m`] via the
142/// modulo-360° longitude operation).
143pub fn synthetic_grid(model: GeoidModel) -> GeoidGrid {
144 let lat_step_deg = 2.0_f64;
145 let lon_step_deg = 2.0_f64;
146 let lat_min_deg = -90.0_f64;
147 let lon_min_deg = -180.0_f64;
148 // South pole at row 0; north pole at row n_lat-1. Inclusive bounds.
149 let n_lat: usize = 91;
150 let n_lon: usize = 180;
151 let mut heights_m = Vec::with_capacity(n_lat * n_lon);
152 for i in 0..n_lat {
153 let lat_deg = lat_min_deg + (i as f64) * lat_step_deg;
154 let lat_rad = lat_deg.to_radians();
155 let sin_lat = lat_rad.sin();
156 for j in 0..n_lon {
157 let lon_deg = lon_min_deg + (j as f64) * lon_step_deg;
158 let lon_rad = lon_deg.to_radians();
159 let cos_lon = lon_rad.cos();
160 heights_m.push((30.0_f64 * sin_lat * cos_lon) as f32);
161 }
162 }
163 GeoidGrid {
164 model,
165 lat_step_deg,
166 lon_step_deg,
167 lat_min_deg,
168 lon_min_deg,
169 n_lat,
170 n_lon,
171 heights_m,
172 }
173}
174
175/// Analytic synthetic height (m) used by [`synthetic_grid`] — exposed for
176/// test verification. This is **not** a physical geoid; it merely produces a
177/// deterministic, smooth field.
178pub fn synthetic_height_m(lat_deg: f64, lon_deg: f64) -> f64 {
179 30.0_f64 * lat_deg.to_radians().sin() * lon_deg.to_radians().cos()
180}
181
182// ---------------------------------------------------------------------------
183// File loader (NGA little-endian f32 layout)
184// ---------------------------------------------------------------------------
185
186/// Loads an NGA-format little-endian f32 geoid grid file.
187///
188/// The expected on-disk layout is row-major, with rows ordered south-to-north
189/// (i.e. the first row corresponds to `lat_min_deg`) and columns ordered
190/// west-to-east (the first column corresponds to `lon_min_deg`). Each cell is
191/// a little-endian IEEE-754 `f32`.
192///
193/// The file size **must** equal `n_lat * n_lon * 4` bytes; if it does not, an
194/// [`Error::GeoidFileFormat`] is returned. No header is assumed — callers
195/// must supply the geometry parameters out-of-band (typically from a
196/// companion `.bin.hdr`).
197///
198/// # Errors
199///
200/// * [`Error::GeoidFileFormat`] — file unreadable, or its size does not match
201/// the declared geometry.
202#[cfg(feature = "std")]
203#[allow(clippy::too_many_arguments)]
204pub fn load_egm_grid(
205 path: &Path,
206 model: GeoidModel,
207 lat_min_deg: f64,
208 lon_min_deg: f64,
209 lat_step_deg: f64,
210 lon_step_deg: f64,
211 n_lat: usize,
212 n_lon: usize,
213) -> Result<GeoidGrid, Error> {
214 let bytes = std::fs::read(path).map_err(|e| {
215 Error::GeoidFileFormat(format!(
216 "failed to read geoid grid file {}: {}",
217 path.display(),
218 e
219 ))
220 })?;
221 let expected_len = n_lat * n_lon * 4;
222 if bytes.len() != expected_len {
223 return Err(Error::GeoidFileFormat(format!(
224 "geoid grid file {} has {} bytes but declared geometry ({} × {} × 4 = {} bytes) does not match",
225 path.display(),
226 bytes.len(),
227 n_lat,
228 n_lon,
229 expected_len
230 )));
231 }
232 let mut heights_m: Vec<f32> = Vec::with_capacity(n_lat * n_lon);
233 for chunk in bytes.chunks_exact(4) {
234 let raw = [chunk[0], chunk[1], chunk[2], chunk[3]];
235 heights_m.push(f32::from_le_bytes(raw));
236 }
237 Ok(GeoidGrid {
238 model,
239 lat_step_deg,
240 lon_step_deg,
241 lat_min_deg,
242 lon_min_deg,
243 n_lat,
244 n_lon,
245 heights_m,
246 })
247}
248
249// ---------------------------------------------------------------------------
250// Interpolation and height conversions
251// ---------------------------------------------------------------------------
252
253impl GeoidGrid {
254 /// Returns the number of grid samples (`n_lat * n_lon`).
255 pub fn len(&self) -> usize {
256 self.n_lat.saturating_mul(self.n_lon)
257 }
258
259 /// Returns `true` when the grid contains no samples.
260 pub fn is_empty(&self) -> bool {
261 self.n_lat == 0 || self.n_lon == 0
262 }
263
264 /// Returns the height stored at exact grid node `(i_lat, j_lon)`.
265 /// Out-of-bounds indices return `None`.
266 pub fn height_at_node_m(&self, i_lat: usize, j_lon: usize) -> Option<f64> {
267 if i_lat >= self.n_lat || j_lon >= self.n_lon {
268 return None;
269 }
270 let idx = i_lat
271 .checked_mul(self.n_lon)
272 .and_then(|p| p.checked_add(j_lon))?;
273 self.heights_m.get(idx).map(|h| *h as f64)
274 }
275
276 /// Bilinear-interpolated geoid undulation at `(lat_deg, lon_deg)`.
277 ///
278 /// * Longitude wraps modulo 360° relative to `lon_min_deg`, so queries
279 /// at +180° and −180° return identical values for a global grid.
280 /// * Latitude **clamps** to the grid bounds — querying at 91° returns
281 /// the value at the northernmost row.
282 /// * Returns `0.0` when the grid is empty (no samples).
283 pub fn geoid_height_m(&self, lat_deg: f64, lon_deg: f64) -> f64 {
284 if self.is_empty() {
285 return 0.0;
286 }
287
288 // ----- latitude (clamp) ---------------------------------------------
289 let lat_max = self.lat_min_deg + (self.n_lat.saturating_sub(1)) as f64 * self.lat_step_deg;
290 let lat = lat_deg.clamp(self.lat_min_deg, lat_max);
291 let lat_offset = lat - self.lat_min_deg;
292 let lat_step = if self.lat_step_deg.abs() < f64::EPSILON {
293 1.0
294 } else {
295 self.lat_step_deg
296 };
297 let lat_idx_f = lat_offset / lat_step;
298 let lat_idx0_isize = lat_idx_f.floor() as isize;
299 let lat_idx0 = if lat_idx0_isize < 0 {
300 0
301 } else {
302 (lat_idx0_isize as usize).min(self.n_lat.saturating_sub(1))
303 };
304 let lat_idx1 = lat_idx0.saturating_add(1).min(self.n_lat.saturating_sub(1));
305 let dt = (lat_idx_f - lat_idx0 as f64).clamp(0.0, 1.0);
306
307 // ----- longitude (wrap modulo 360) ----------------------------------
308 let lon_step = if self.lon_step_deg.abs() < f64::EPSILON {
309 1.0
310 } else {
311 self.lon_step_deg
312 };
313 // Offset from origin, wrapped into [0, 360).
314 let lon_offset = (lon_deg - self.lon_min_deg).rem_euclid(360.0);
315 let lon_idx_f = lon_offset / lon_step;
316 let lon_idx0_isize = lon_idx_f.floor() as isize;
317 // Wrap the column index modulo n_lon (grids may not exactly cover
318 // 360°; this matches the synthetic_grid layout of step=2°, n_lon=180).
319 let n_lon = self.n_lon;
320 let lon_idx0 = if n_lon == 0 {
321 0
322 } else {
323 ((lon_idx0_isize.rem_euclid(n_lon as isize)) as usize) % n_lon
324 };
325 let lon_idx1 = if n_lon == 0 {
326 0
327 } else {
328 (lon_idx0 + 1) % n_lon
329 };
330 let du = (lon_idx_f - lon_idx_f.floor()).clamp(0.0, 1.0);
331
332 // ----- gather four corners ------------------------------------------
333 let h00 = self.height_at_node_m(lat_idx0, lon_idx0).unwrap_or(0.0);
334 let h01 = self.height_at_node_m(lat_idx0, lon_idx1).unwrap_or(0.0);
335 let h10 = self.height_at_node_m(lat_idx1, lon_idx0).unwrap_or(0.0);
336 let h11 = self.height_at_node_m(lat_idx1, lon_idx1).unwrap_or(0.0);
337
338 // ----- bilinear interpolation ---------------------------------------
339 let h0 = h00 * (1.0 - du) + h01 * du;
340 let h1 = h10 * (1.0 - du) + h11 * du;
341 h0 * (1.0 - dt) + h1 * dt
342 }
343
344 /// Converts an orthometric height (above the geoid) into an ellipsoidal
345 /// height (above the reference ellipsoid) using `h_ellip = h_ortho + N`.
346 pub fn orthometric_to_ellipsoidal(&self, lat_deg: f64, lon_deg: f64, h_ortho_m: f64) -> f64 {
347 h_ortho_m + self.geoid_height_m(lat_deg, lon_deg)
348 }
349
350 /// Converts an ellipsoidal height into an orthometric height using
351 /// `h_ortho = h_ellip − N`.
352 pub fn ellipsoidal_to_orthometric(&self, lat_deg: f64, lon_deg: f64, h_ellip_m: f64) -> f64 {
353 h_ellip_m - self.geoid_height_m(lat_deg, lon_deg)
354 }
355}
356
357// ---------------------------------------------------------------------------
358// Vertical-datum classification (used by the Transformer compound-CRS branch)
359// ---------------------------------------------------------------------------
360
361/// Coarse vertical-datum kind inferred from a CRS name / datum string.
362///
363/// Used by [`crate::transform::Transformer::transform_3d`] to decide whether a
364/// geoid undulation correction is required between two compound CRS. The
365/// classification is **best-effort** and relies on simple substring matching
366/// of well-known tokens (`"ellipsoid"`, `"geoid"`, `"egm"`, `"navd"`,
367/// `"orthometric"`).
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum VerticalDatumKind {
370 /// Heights are referenced to the reference ellipsoid (e.g. WGS84).
371 Ellipsoidal,
372 /// Heights are referenced to a geoid (e.g. EGM96, EGM2008, NAVD88).
373 Orthometric,
374 /// Datum kind could not be classified from the available metadata.
375 Unknown,
376}
377
378/// Classifies a vertical-CRS description string into a [`VerticalDatumKind`].
379///
380/// Matching is case-insensitive. The first match wins in the order
381/// orthometric → ellipsoidal, so a string containing both tokens is reported
382/// as orthometric (the more specific category in practice).
383pub fn classify_vertical_datum(description: &str) -> VerticalDatumKind {
384 let lower = description.to_ascii_lowercase();
385 const ORTHOMETRIC_TOKENS: &[&str] = &[
386 "egm96",
387 "egm2008",
388 "egm",
389 "navd88",
390 "navd",
391 "ngvd",
392 "geoid",
393 "orthometric",
394 "mean sea level",
395 "msl",
396 "amsl",
397 ];
398 const ELLIPSOIDAL_TOKENS: &[&str] = &[
399 "ellipsoid",
400 "ellipsoidal",
401 "wgs84 height",
402 "wgs 84 height",
403 "ellh",
404 ];
405
406 for tok in ORTHOMETRIC_TOKENS {
407 if lower.contains(tok) {
408 return VerticalDatumKind::Orthometric;
409 }
410 }
411 for tok in ELLIPSOIDAL_TOKENS {
412 if lower.contains(tok) {
413 return VerticalDatumKind::Ellipsoidal;
414 }
415 }
416 VerticalDatumKind::Unknown
417}
418
419// ===========================================================================
420// Tests
421// ===========================================================================
422
423#[cfg(test)]
424#[allow(clippy::expect_used)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_geoid_model_display_name() {
430 assert_eq!(GeoidModel::Egm96.display_name(), "EGM96");
431 assert_eq!(GeoidModel::Egm2008.display_name(), "EGM2008");
432 assert_eq!(format!("{}", GeoidModel::Egm96), "EGM96");
433 }
434
435 #[test]
436 fn test_synthetic_grid_dimensions() {
437 let g = synthetic_grid(GeoidModel::Egm96);
438 assert_eq!(g.n_lat, 91);
439 assert_eq!(g.n_lon, 180);
440 assert_eq!(g.heights_m.len(), 91 * 180);
441 assert!((g.lat_step_deg - 2.0).abs() < f64::EPSILON);
442 assert!((g.lon_step_deg - 2.0).abs() < f64::EPSILON);
443 }
444
445 #[test]
446 fn test_grid_height_at_node_returns_stored_value() {
447 let g = synthetic_grid(GeoidModel::Egm96);
448 // Exact node at (lat=0, lon=0)
449 let i_lat = ((0.0 - g.lat_min_deg) / g.lat_step_deg) as usize; // 45
450 let j_lon = ((0.0 - g.lon_min_deg) / g.lon_step_deg) as usize; // 90
451 let stored = g.height_at_node_m(i_lat, j_lon).expect("must be in bounds");
452 let expected = synthetic_height_m(0.0, 0.0);
453 assert!(
454 (stored - expected).abs() < 1e-3,
455 "stored={stored}, expected={expected}"
456 );
457 }
458
459 #[test]
460 fn test_classify_vertical_datum() {
461 assert_eq!(
462 classify_vertical_datum("EGM96 height"),
463 VerticalDatumKind::Orthometric
464 );
465 assert_eq!(
466 classify_vertical_datum("WGS 84 ellipsoidal height"),
467 VerticalDatumKind::Ellipsoidal
468 );
469 assert_eq!(
470 classify_vertical_datum("Some custom datum"),
471 VerticalDatumKind::Unknown
472 );
473 assert_eq!(
474 classify_vertical_datum("NAVD88"),
475 VerticalDatumKind::Orthometric
476 );
477 }
478
479 #[test]
480 fn test_orthometric_ellipsoidal_round_trip_inline() {
481 let g = synthetic_grid(GeoidModel::Egm2008);
482 let h0 = 123.456_f64;
483 let h_ellip = g.orthometric_to_ellipsoidal(30.0, 45.0, h0);
484 let h_back = g.ellipsoidal_to_orthometric(30.0, 45.0, h_ellip);
485 assert!((h_back - h0).abs() < 1e-9);
486 }
487}