1use std::sync::OnceLock;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum GeoidError {
40 InvalidDimensions {
42 expected: usize,
44 found: usize,
46 },
47 InvalidSpacing {
49 field: &'static str,
51 },
52 NonFiniteValue {
54 index: usize,
56 },
57 Parse {
59 reason: String,
61 },
62}
63
64impl core::fmt::Display for GeoidError {
65 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66 match self {
67 Self::InvalidDimensions { expected, found } => {
68 write!(
69 f,
70 "geoid grid expected {expected} samples but found {found}"
71 )
72 }
73 Self::InvalidSpacing { field } => {
74 write!(f, "geoid grid {field} must be finite and positive")
75 }
76 Self::NonFiniteValue { index } => {
77 write!(f, "geoid grid sample {index} is not finite")
78 }
79 Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
80 }
81 }
82}
83
84impl std::error::Error for GeoidError {}
85
86#[derive(Debug, Clone, PartialEq)]
98pub struct GeoidGrid {
99 lat_min_deg: f64,
100 lon_min_deg: f64,
101 dlat_deg: f64,
102 dlon_deg: f64,
103 n_lat: usize,
104 n_lon: usize,
105 values_m: Vec<f64>,
106}
107
108impl GeoidGrid {
109 pub fn new(
116 lat_min_deg: f64,
117 lon_min_deg: f64,
118 dlat_deg: f64,
119 dlon_deg: f64,
120 n_lat: usize,
121 n_lon: usize,
122 values_m: Vec<f64>,
123 ) -> Result<Self, GeoidError> {
124 if n_lat == 0 || n_lon == 0 {
125 return Err(GeoidError::InvalidDimensions {
126 expected: 1,
127 found: 0,
128 });
129 }
130 let expected = n_lat * n_lon;
131 if values_m.len() != expected {
132 return Err(GeoidError::InvalidDimensions {
133 expected,
134 found: values_m.len(),
135 });
136 }
137 if !lat_min_deg.is_finite() {
138 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
139 }
140 if !lon_min_deg.is_finite() {
141 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
142 }
143 if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
144 return Err(GeoidError::InvalidSpacing { field: "dlat" });
145 }
146 if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
147 return Err(GeoidError::InvalidSpacing { field: "dlon" });
148 }
149 for (index, value) in values_m.iter().enumerate() {
150 if !value.is_finite() {
151 return Err(GeoidError::NonFiniteValue { index });
152 }
153 }
154 Ok(Self {
155 lat_min_deg,
156 lon_min_deg,
157 dlat_deg,
158 dlon_deg,
159 n_lat,
160 n_lon,
161 values_m,
162 })
163 }
164
165 pub fn from_text(text: &str) -> Result<Self, GeoidError> {
183 let mut tokens = text
184 .lines()
185 .map(|line| line.split('#').next().unwrap_or(""))
186 .flat_map(str::split_whitespace);
187
188 let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
189 let token = tokens.next().ok_or_else(|| GeoidError::Parse {
190 reason: format!("missing header field {field}"),
191 })?;
192 token.parse::<f64>().map_err(|_| GeoidError::Parse {
193 reason: format!("header field {field} is not a number: {token:?}"),
194 })
195 };
196
197 let lat_min_deg = next_field("lat_min")?;
198 let lon_min_deg = next_field("lon_min")?;
199 let dlat_deg = next_field("dlat")?;
200 let dlon_deg = next_field("dlon")?;
201 let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
202 let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
203
204 let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
205 reason: "n_lat * n_lon overflows".to_string(),
206 })?;
207 let mut values_m = Vec::with_capacity(expected);
208 for token in tokens {
209 let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
210 reason: format!("sample is not a number: {token:?}"),
211 })?;
212 values_m.push(value);
213 }
214
215 Self::new(
216 lat_min_deg,
217 lon_min_deg,
218 dlat_deg,
219 dlon_deg,
220 n_lat,
221 n_lon,
222 values_m,
223 )
224 }
225
226 fn is_global_longitude(&self) -> bool {
229 ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
230 || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
231 }
232
233 pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
236 self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
237 }
238
239 pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
242 let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
243 let (i0, i1, ty) = self.lat_bracket(lat);
244
245 let (j0, j1, tx) = self.lon_bracket(lon_deg);
246
247 let v00 = self.sample(i0, j0);
248 let v01 = self.sample(i0, j1);
249 let v10 = self.sample(i1, j0);
250 let v11 = self.sample(i1, j1);
251
252 let bottom = v00 + (v01 - v00) * tx;
253 let top = v10 + (v11 - v10) * tx;
254 bottom + (top - bottom) * ty
255 }
256
257 fn lat_max_deg(&self) -> f64 {
258 self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
259 }
260
261 fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
263 if self.n_lat == 1 {
264 return (0, 0, 0.0);
265 }
266 let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
267 let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
268 let i0 = (pos.floor() as usize).min(self.n_lat - 2);
269 (i0, i0 + 1, pos - i0 as f64)
270 }
271
272 fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
275 if self.n_lon == 1 {
276 return (0, 0, 0.0);
277 }
278 let lon = normalize_longitude_deg(lon_deg);
279 if self.is_global_longitude() {
280 let span = self.n_lon as f64 * self.dlon_deg;
281 let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
282 if offset >= span {
284 offset -= span;
285 }
286 let pos = offset / self.dlon_deg;
287 let j0 = (pos.floor() as usize) % self.n_lon;
288 let j1 = (j0 + 1) % self.n_lon;
289 (j0, j1, pos - pos.floor())
290 } else {
291 let pos =
292 ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
293 let j0 = (pos.floor() as usize).min(self.n_lon - 2);
294 (j0, j0 + 1, pos - j0 as f64)
295 }
296 }
297
298 fn sample(&self, i: usize, j: usize) -> f64 {
299 self.values_m[i * self.n_lon + j]
300 }
301}
302
303fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
305 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
306 return Err(GeoidError::Parse {
307 reason: format!("{field} must be a positive integer, got {value}"),
308 });
309 }
310 Ok(value as usize)
311}
312
313fn normalize_longitude_deg(lon_deg: f64) -> f64 {
315 let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
316 if wrapped >= 180.0 {
318 wrapped - 360.0
319 } else {
320 wrapped
321 }
322}
323
324pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
332 builtin_grid().undulation_rad(lat_rad, lon_rad)
333}
334
335pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
340 ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
341}
342
343pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
348 orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
349}
350
351fn builtin_grid() -> &'static GeoidGrid {
353 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
354 GRID.get_or_init(|| {
355 GeoidGrid::new(
356 -90.0,
357 -180.0,
358 30.0,
359 30.0,
360 BUILTIN_N_LAT,
361 BUILTIN_N_LON,
362 BUILTIN_VALUES_M.to_vec(),
363 )
364 .expect("built-in geoid grid is well-formed")
365 })
366}
367
368const BUILTIN_N_LAT: usize = 7; const BUILTIN_N_LON: usize = 13; #[rustfmt::skip]
378const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
379 -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,
381 -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,
383 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,
385 -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,
387 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,
389 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,
391 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,
393];
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn builtin_returns_exact_node_values() {
401 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
403 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
405 assert_eq!(
407 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
408 60.0
409 );
410 }
411
412 #[test]
413 fn builtin_captures_major_geoid_features_by_sign() {
414 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
416 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
417 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
419 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
420 }
421
422 #[test]
423 fn bilinear_midpoint_is_the_corner_average() {
424 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
425 let center = grid.undulation_deg(5.0, 5.0);
427 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
428 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
430 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
431 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
433 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
434 }
435
436 #[test]
437 fn global_grid_wraps_across_the_antimeridian() {
438 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
442 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
443 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
444 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
446 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
447 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
449 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
450 assert_eq!(plus, minus);
451 assert_eq!(plus, -10.0);
452 }
453
454 #[test]
455 fn orthometric_height_subtracts_undulation() {
456 let lat = 0.0;
457 let lon = 0.0;
458 let n = geoid_undulation(lat, lon);
459 assert_eq!(n, 17.0);
460 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
462 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
464 }
465
466 #[test]
467 fn from_text_round_trips_a_grid() {
468 let text = "\
469# coarse 2x3 regional grid
470# lat_min lon_min dlat dlon n_lat n_lon
47110.0 20.0 5.0 5.0 2 3
472 1.0 2.0 3.0 # lat 10 row
473 4.0 5.0 6.0 # lat 15 row
474";
475 let grid = GeoidGrid::from_text(text).expect("parse grid");
476 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
477 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
478 let center = grid.undulation_deg(12.5, 22.5);
480 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
481 assert_eq!(
483 grid.undulation_deg(10.0, 0.0),
484 grid.undulation_deg(10.0, 20.0)
485 );
486 }
487
488 #[test]
489 fn from_text_rejects_short_data() {
490 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
491 assert_eq!(
492 GeoidGrid::from_text(text),
493 Err(GeoidError::InvalidDimensions {
494 expected: 4,
495 found: 3
496 })
497 );
498 }
499
500 #[test]
501 fn new_rejects_bad_inputs() {
502 assert!(matches!(
503 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
504 Err(GeoidError::InvalidDimensions { .. })
505 ));
506 assert!(matches!(
507 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
508 Err(GeoidError::InvalidSpacing { field: "dlat" })
509 ));
510 assert!(matches!(
511 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
512 Err(GeoidError::NonFiniteValue { index: 1 })
513 ));
514 }
515
516 #[test]
517 fn longitude_normalization_folds_into_half_open_interval() {
518 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
519 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
520 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
521 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
522 }
523}