Skip to main content

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}