use std::path::Path;
use crate::error::Error;
use crate::geoid::{GeoidGrid, GeoidModel};
const GRID_LAT_MIN_DEG: f64 = -90.0;
const GRID_LON_MIN_DEG: f64 = -180.0;
const MAX_GRID_DIM: u32 = 100_000;
const EGM2008_HEADER_LEN: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Egm96AsciiHeader {
pub lat_min: f64,
pub lat_max: f64,
pub lon_min: f64,
pub lon_max: f64,
pub lat_step: f64,
pub lon_step: f64,
pub n_lat: usize,
pub n_lon: usize,
}
pub fn parse_egm96_ascii_header(s: &str) -> Result<Egm96AsciiHeader, Error> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(Error::GeoidFileFormat(
"EGM96 ASCII header line is empty".to_string(),
));
}
let mut values: [f64; 6] = [0.0; 6];
let mut tokens = trimmed.split_whitespace();
for (slot, label) in values.iter_mut().zip([
"south_lat",
"north_lat",
"west_lon",
"east_lon",
"dlat",
"dlon",
]) {
let tok = tokens.next().ok_or_else(|| {
Error::GeoidFileFormat(format!(
"EGM96 ASCII header line is truncated: expected 6 numbers \
(south_lat north_lat west_lon east_lon dlat dlon), missing '{label}' in {trimmed:?}"
))
})?;
let parsed: f64 = tok.parse().map_err(|_| {
Error::GeoidFileFormat(format!(
"EGM96 ASCII header field '{label}' is not a valid number: {tok:?}"
))
})?;
if !parsed.is_finite() {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header field '{label}' is not finite: {tok:?}"
)));
}
*slot = parsed;
}
let [lat_min, lat_max, lon_min, lon_max, lat_step, lon_step] = values;
if lat_step <= 0.0 {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header latitude step must be positive, got {lat_step}"
)));
}
if lon_step <= 0.0 {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header longitude step must be positive, got {lon_step}"
)));
}
if lat_max < lat_min {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header latitude bounds inverted: north {lat_max} < south {lat_min}"
)));
}
if lon_max < lon_min {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header longitude bounds inverted: east {lon_max} < west {lon_min}"
)));
}
let n_lat = derive_node_count(lat_min, lat_max, lat_step, "latitude")?;
let n_lon = derive_node_count(lon_min, lon_max, lon_step, "longitude")?;
Ok(Egm96AsciiHeader {
lat_min,
lat_max,
lon_min,
lon_max,
lat_step,
lon_step,
n_lat,
n_lon,
})
}
fn derive_node_count(min: f64, max: f64, step: f64, axis: &str) -> Result<usize, Error> {
let span_steps = ((max - min) / step).round();
if !span_steps.is_finite() || span_steps < 0.0 {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header {axis} span is invalid (min={min}, max={max}, step={step})"
)));
}
let count = span_steps as u64 + 1;
if count == 0 || count > MAX_GRID_DIM as u64 {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII header {axis} node count {count} is out of range (1..={MAX_GRID_DIM})"
)));
}
Ok(count as usize)
}
pub fn parse_egm96_ascii_str(s: &str) -> Result<GeoidGrid, Error> {
let mut lines = s.lines();
let header_line = loop {
match lines.next() {
Some(line) if !line.trim().is_empty() => break line,
Some(_) => continue,
None => {
return Err(Error::GeoidFileFormat(
"EGM96 ASCII input contains no header line".to_string(),
));
}
}
};
let header = parse_egm96_ascii_header(header_line)?;
let expected = header.n_lat.checked_mul(header.n_lon).ok_or_else(|| {
Error::GeoidFileFormat(format!(
"EGM96 ASCII grid size overflow: {} × {}",
header.n_lat, header.n_lon
))
})?;
let mut heights_m: Vec<f32> = Vec::with_capacity(expected);
for line in lines {
for tok in line.split_whitespace() {
let value: f64 = tok.parse().map_err(|_| {
Error::GeoidFileFormat(format!(
"EGM96 ASCII body contains a non-numeric undulation value: {tok:?}"
))
})?;
if !value.is_finite() {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII body undulation value is not finite: {tok:?}"
)));
}
if heights_m.len() == expected {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII body has more values than the header declares \
({} expected for {} × {})",
expected, header.n_lat, header.n_lon
)));
}
heights_m.push(value as f32);
}
}
if heights_m.len() != expected {
return Err(Error::GeoidFileFormat(format!(
"EGM96 ASCII body has {} undulation values but the header declares \
{} ({} rows × {} cols)",
heights_m.len(),
expected,
header.n_lat,
header.n_lon
)));
}
Ok(GeoidGrid {
model: GeoidModel::Egm96,
lat_step_deg: header.lat_step,
lon_step_deg: header.lon_step,
lat_min_deg: header.lat_min,
lon_min_deg: header.lon_min,
n_lat: header.n_lat,
n_lon: header.n_lon,
heights_m,
})
}
pub fn parse_egm96_ascii_file(path: &Path) -> Result<GeoidGrid, Error> {
let text = std::fs::read_to_string(path).map_err(|e| {
Error::GeoidFileFormat(format!(
"failed to read EGM96 ASCII grid file {}: {e}",
path.display()
))
})?;
parse_egm96_ascii_str(&text)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Egm2008BinaryHeader {
pub n_lat: u32,
pub n_lon: u32,
}
pub fn parse_egm2008_binary_25_header(bytes: &[u8]) -> Result<Egm2008BinaryHeader, Error> {
if bytes.len() < EGM2008_HEADER_LEN {
return Err(Error::GeoidFileFormat(format!(
"EGM2008 binary header requires at least {EGM2008_HEADER_LEN} bytes, got {}",
bytes.len()
)));
}
let n_lat = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let n_lon = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
if n_lat == 0 || n_lon == 0 {
return Err(Error::GeoidFileFormat(format!(
"EGM2008 binary header dimensions must be non-zero (n_lat={n_lat}, n_lon={n_lon})"
)));
}
if n_lat > MAX_GRID_DIM || n_lon > MAX_GRID_DIM {
return Err(Error::GeoidFileFormat(format!(
"EGM2008 binary header dimensions out of range \
(n_lat={n_lat}, n_lon={n_lon}, max={MAX_GRID_DIM})"
)));
}
Ok(Egm2008BinaryHeader { n_lat, n_lon })
}
pub fn parse_egm2008_binary_25(bytes: &[u8]) -> Result<GeoidGrid, Error> {
let header = parse_egm2008_binary_25_header(bytes)?;
let n_lat = header.n_lat as usize;
let n_lon = header.n_lon as usize;
let cells = n_lat
.checked_mul(n_lon)
.ok_or_else(|| Error::GeoidFileFormat("EGM2008 binary grid size overflow".to_string()))?;
let body_len = cells
.checked_mul(4)
.ok_or_else(|| Error::GeoidFileFormat("EGM2008 binary body size overflow".to_string()))?;
let expected_len = body_len
.checked_add(EGM2008_HEADER_LEN)
.ok_or_else(|| Error::GeoidFileFormat("EGM2008 binary total size overflow".to_string()))?;
if bytes.len() != expected_len {
return Err(Error::GeoidFileFormat(format!(
"EGM2008 binary grid has {} bytes but header dimensions \
({n_lat} × {n_lon}) require exactly {expected_len} \
({EGM2008_HEADER_LEN}-byte header + {cells} × 4-byte f32 body)",
bytes.len()
)));
}
let body = &bytes[EGM2008_HEADER_LEN..];
let mut heights_m: Vec<f32> = Vec::with_capacity(cells);
for chunk in body.chunks_exact(4) {
heights_m.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
let lat_step_deg = if n_lat <= 1 {
0.0
} else {
180.0 / (n_lat as f64 - 1.0)
};
let lon_step_deg = 360.0 / n_lon as f64;
Ok(GeoidGrid {
model: GeoidModel::Egm2008,
lat_step_deg,
lon_step_deg,
lat_min_deg: GRID_LAT_MIN_DEG,
lon_min_deg: GRID_LON_MIN_DEG,
n_lat,
n_lon,
heights_m,
})
}
pub fn parse_egm2008_binary_25_file(path: &Path) -> Result<GeoidGrid, Error> {
let bytes = std::fs::read(path).map_err(|e| {
Error::GeoidFileFormat(format!(
"failed to read EGM2008 binary grid file {}: {e}",
path.display()
))
})?;
parse_egm2008_binary_25(&bytes)
}
pub fn load_geoid_auto(path: &Path) -> Result<GeoidGrid, Error> {
let bytes = std::fs::read(path).map_err(|e| {
Error::GeoidFileFormat(format!(
"failed to read geoid grid file {}: {e}",
path.display()
))
})?;
let sniff = &bytes[..bytes.len().min(16)];
let leading = sniff.iter().copied().find(|b| !b.is_ascii_whitespace());
match leading {
Some(b) if b.is_ascii_digit() || b == b'+' || b == b'-' || b == b'.' => {
parse_egm96_ascii_file(path)
}
Some(_) => {
parse_egm2008_binary_25_header(&bytes).map_err(|e| {
Error::GeoidFileFormat(format!(
"ambiguous/unknown geoid format for {}: leading byte 0x{:02x} is \
not numeric-ASCII and the file is not a valid EGM2008 binary grid ({e})",
path.display(),
leading.unwrap_or(0)
))
})?;
parse_egm2008_binary_25(&bytes)
}
None => Err(Error::GeoidFileFormat(format!(
"ambiguous/unknown geoid format for {}: file is empty or all-whitespace",
path.display()
))),
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_header_derives_inclusive_node_counts() {
let h = parse_egm96_ascii_header("-90 90 -180 180 0.25 0.25").expect("valid header");
assert_eq!(h.n_lat, 721);
assert_eq!(h.n_lon, 1441);
assert!((h.lat_step - 0.25).abs() < f64::EPSILON);
}
#[test]
fn test_header_rejects_non_numeric_token() {
let err = parse_egm96_ascii_header("-90 NORTH -180 180 0.25 0.25")
.expect_err("non-numeric token must fail");
assert!(matches!(err, Error::GeoidFileFormat(_)));
}
#[test]
fn test_binary_header_round_trips_canonical_dims() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&4321u32.to_le_bytes());
bytes.extend_from_slice(&8640u32.to_le_bytes());
let h = parse_egm2008_binary_25_header(&bytes).expect("valid header");
assert_eq!(h.n_lat, 4321);
assert_eq!(h.n_lon, 8640);
}
#[test]
fn test_binary_rejects_short_header() {
let err = parse_egm2008_binary_25_header(&[0u8; 4]).expect_err("4 bytes is too short");
assert!(matches!(err, Error::GeoidFileFormat(_)));
}
#[test]
fn test_egm2008_step_derivation() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&3u32.to_le_bytes());
bytes.extend_from_slice(&3u32.to_le_bytes());
bytes.extend_from_slice(&[0u8; 3 * 3 * 4]);
let g = parse_egm2008_binary_25(&bytes).expect("valid grid");
assert!((g.lat_step_deg - 90.0).abs() < 1e-12);
assert!((g.lon_step_deg - 120.0).abs() < 1e-12);
assert!((g.lat_min_deg - GRID_LAT_MIN_DEG).abs() < f64::EPSILON);
assert!((g.lon_min_deg - GRID_LON_MIN_DEG).abs() < f64::EPSILON);
}
}