#[cfg(feature = "std")]
use std::path::Path;
use crate::error::Error;
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GeoidModel {
Egm96,
Egm2008,
}
impl GeoidModel {
pub fn display_name(&self) -> &'static str {
match self {
Self::Egm96 => "EGM96",
Self::Egm2008 => "EGM2008",
}
}
pub fn default_grid_spacing_deg(&self) -> f64 {
match self {
Self::Egm96 => 0.25,
Self::Egm2008 => 0.25,
}
}
pub fn approx_accuracy_cm(&self) -> f64 {
match self {
Self::Egm96 => 50.0,
Self::Egm2008 => 10.0,
}
}
}
impl core::fmt::Display for GeoidModel {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.display_name())
}
}
#[derive(Debug, Clone)]
pub struct GeoidGrid {
pub model: GeoidModel,
pub lat_step_deg: f64,
pub lon_step_deg: f64,
pub lat_min_deg: f64,
pub lon_min_deg: f64,
pub n_lat: usize,
pub n_lon: usize,
pub heights_m: Vec<f32>,
}
pub fn synthetic_grid(model: GeoidModel) -> GeoidGrid {
let lat_step_deg = 2.0_f64;
let lon_step_deg = 2.0_f64;
let lat_min_deg = -90.0_f64;
let lon_min_deg = -180.0_f64;
let n_lat: usize = 91;
let n_lon: usize = 180;
let mut heights_m = Vec::with_capacity(n_lat * n_lon);
for i in 0..n_lat {
let lat_deg = lat_min_deg + (i as f64) * lat_step_deg;
let lat_rad = lat_deg.to_radians();
let sin_lat = lat_rad.sin();
for j in 0..n_lon {
let lon_deg = lon_min_deg + (j as f64) * lon_step_deg;
let lon_rad = lon_deg.to_radians();
let cos_lon = lon_rad.cos();
heights_m.push((30.0_f64 * sin_lat * cos_lon) as f32);
}
}
GeoidGrid {
model,
lat_step_deg,
lon_step_deg,
lat_min_deg,
lon_min_deg,
n_lat,
n_lon,
heights_m,
}
}
pub fn synthetic_height_m(lat_deg: f64, lon_deg: f64) -> f64 {
30.0_f64 * lat_deg.to_radians().sin() * lon_deg.to_radians().cos()
}
#[cfg(feature = "std")]
#[allow(clippy::too_many_arguments)]
pub fn load_egm_grid(
path: &Path,
model: GeoidModel,
lat_min_deg: f64,
lon_min_deg: f64,
lat_step_deg: f64,
lon_step_deg: f64,
n_lat: usize,
n_lon: usize,
) -> Result<GeoidGrid, Error> {
let bytes = std::fs::read(path).map_err(|e| {
Error::GeoidFileFormat(format!(
"failed to read geoid grid file {}: {}",
path.display(),
e
))
})?;
let expected_len = n_lat * n_lon * 4;
if bytes.len() != expected_len {
return Err(Error::GeoidFileFormat(format!(
"geoid grid file {} has {} bytes but declared geometry ({} × {} × 4 = {} bytes) does not match",
path.display(),
bytes.len(),
n_lat,
n_lon,
expected_len
)));
}
let mut heights_m: Vec<f32> = Vec::with_capacity(n_lat * n_lon);
for chunk in bytes.chunks_exact(4) {
let raw = [chunk[0], chunk[1], chunk[2], chunk[3]];
heights_m.push(f32::from_le_bytes(raw));
}
Ok(GeoidGrid {
model,
lat_step_deg,
lon_step_deg,
lat_min_deg,
lon_min_deg,
n_lat,
n_lon,
heights_m,
})
}
impl GeoidGrid {
pub fn len(&self) -> usize {
self.n_lat.saturating_mul(self.n_lon)
}
pub fn is_empty(&self) -> bool {
self.n_lat == 0 || self.n_lon == 0
}
pub fn height_at_node_m(&self, i_lat: usize, j_lon: usize) -> Option<f64> {
if i_lat >= self.n_lat || j_lon >= self.n_lon {
return None;
}
let idx = i_lat
.checked_mul(self.n_lon)
.and_then(|p| p.checked_add(j_lon))?;
self.heights_m.get(idx).map(|h| *h as f64)
}
pub fn geoid_height_m(&self, lat_deg: f64, lon_deg: f64) -> f64 {
if self.is_empty() {
return 0.0;
}
let lat_max = self.lat_min_deg + (self.n_lat.saturating_sub(1)) as f64 * self.lat_step_deg;
let lat = lat_deg.clamp(self.lat_min_deg, lat_max);
let lat_offset = lat - self.lat_min_deg;
let lat_step = if self.lat_step_deg.abs() < f64::EPSILON {
1.0
} else {
self.lat_step_deg
};
let lat_idx_f = lat_offset / lat_step;
let lat_idx0_isize = lat_idx_f.floor() as isize;
let lat_idx0 = if lat_idx0_isize < 0 {
0
} else {
(lat_idx0_isize as usize).min(self.n_lat.saturating_sub(1))
};
let lat_idx1 = lat_idx0.saturating_add(1).min(self.n_lat.saturating_sub(1));
let dt = (lat_idx_f - lat_idx0 as f64).clamp(0.0, 1.0);
let lon_step = if self.lon_step_deg.abs() < f64::EPSILON {
1.0
} else {
self.lon_step_deg
};
let lon_offset = (lon_deg - self.lon_min_deg).rem_euclid(360.0);
let lon_idx_f = lon_offset / lon_step;
let lon_idx0_isize = lon_idx_f.floor() as isize;
let n_lon = self.n_lon;
let lon_idx0 = if n_lon == 0 {
0
} else {
((lon_idx0_isize.rem_euclid(n_lon as isize)) as usize) % n_lon
};
let lon_idx1 = if n_lon == 0 {
0
} else {
(lon_idx0 + 1) % n_lon
};
let du = (lon_idx_f - lon_idx_f.floor()).clamp(0.0, 1.0);
let h00 = self.height_at_node_m(lat_idx0, lon_idx0).unwrap_or(0.0);
let h01 = self.height_at_node_m(lat_idx0, lon_idx1).unwrap_or(0.0);
let h10 = self.height_at_node_m(lat_idx1, lon_idx0).unwrap_or(0.0);
let h11 = self.height_at_node_m(lat_idx1, lon_idx1).unwrap_or(0.0);
let h0 = h00 * (1.0 - du) + h01 * du;
let h1 = h10 * (1.0 - du) + h11 * du;
h0 * (1.0 - dt) + h1 * dt
}
pub fn orthometric_to_ellipsoidal(&self, lat_deg: f64, lon_deg: f64, h_ortho_m: f64) -> f64 {
h_ortho_m + self.geoid_height_m(lat_deg, lon_deg)
}
pub fn ellipsoidal_to_orthometric(&self, lat_deg: f64, lon_deg: f64, h_ellip_m: f64) -> f64 {
h_ellip_m - self.geoid_height_m(lat_deg, lon_deg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerticalDatumKind {
Ellipsoidal,
Orthometric,
Unknown,
}
pub fn classify_vertical_datum(description: &str) -> VerticalDatumKind {
let lower = description.to_ascii_lowercase();
const ORTHOMETRIC_TOKENS: &[&str] = &[
"egm96",
"egm2008",
"egm",
"navd88",
"navd",
"ngvd",
"geoid",
"orthometric",
"mean sea level",
"msl",
"amsl",
];
const ELLIPSOIDAL_TOKENS: &[&str] = &[
"ellipsoid",
"ellipsoidal",
"wgs84 height",
"wgs 84 height",
"ellh",
];
for tok in ORTHOMETRIC_TOKENS {
if lower.contains(tok) {
return VerticalDatumKind::Orthometric;
}
}
for tok in ELLIPSOIDAL_TOKENS {
if lower.contains(tok) {
return VerticalDatumKind::Ellipsoidal;
}
}
VerticalDatumKind::Unknown
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_geoid_model_display_name() {
assert_eq!(GeoidModel::Egm96.display_name(), "EGM96");
assert_eq!(GeoidModel::Egm2008.display_name(), "EGM2008");
assert_eq!(format!("{}", GeoidModel::Egm96), "EGM96");
}
#[test]
fn test_synthetic_grid_dimensions() {
let g = synthetic_grid(GeoidModel::Egm96);
assert_eq!(g.n_lat, 91);
assert_eq!(g.n_lon, 180);
assert_eq!(g.heights_m.len(), 91 * 180);
assert!((g.lat_step_deg - 2.0).abs() < f64::EPSILON);
assert!((g.lon_step_deg - 2.0).abs() < f64::EPSILON);
}
#[test]
fn test_grid_height_at_node_returns_stored_value() {
let g = synthetic_grid(GeoidModel::Egm96);
let i_lat = ((0.0 - g.lat_min_deg) / g.lat_step_deg) as usize; let j_lon = ((0.0 - g.lon_min_deg) / g.lon_step_deg) as usize; let stored = g.height_at_node_m(i_lat, j_lon).expect("must be in bounds");
let expected = synthetic_height_m(0.0, 0.0);
assert!(
(stored - expected).abs() < 1e-3,
"stored={stored}, expected={expected}"
);
}
#[test]
fn test_classify_vertical_datum() {
assert_eq!(
classify_vertical_datum("EGM96 height"),
VerticalDatumKind::Orthometric
);
assert_eq!(
classify_vertical_datum("WGS 84 ellipsoidal height"),
VerticalDatumKind::Ellipsoidal
);
assert_eq!(
classify_vertical_datum("Some custom datum"),
VerticalDatumKind::Unknown
);
assert_eq!(
classify_vertical_datum("NAVD88"),
VerticalDatumKind::Orthometric
);
}
#[test]
fn test_orthometric_ellipsoidal_round_trip_inline() {
let g = synthetic_grid(GeoidModel::Egm2008);
let h0 = 123.456_f64;
let h_ellip = g.orthometric_to_ellipsoidal(30.0, 45.0, h0);
let h_back = g.ellipsoidal_to_orthometric(30.0, 45.0, h_ellip);
assert!((h_back - h0).abs() < 1e-9);
}
}