1use std::sync::OnceLock;
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum GeoidError {
77 InvalidDimensions {
79 expected: usize,
81 found: usize,
83 },
84 InvalidSpacing {
86 field: &'static str,
88 },
89 NonFiniteValue {
91 index: usize,
93 },
94 Parse {
96 reason: String,
98 },
99}
100
101impl core::fmt::Display for GeoidError {
102 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
103 match self {
104 Self::InvalidDimensions { expected, found } => {
105 write!(
106 f,
107 "geoid grid expected {expected} samples but found {found}"
108 )
109 }
110 Self::InvalidSpacing { field } => {
111 write!(f, "geoid grid {field} must be finite and positive")
112 }
113 Self::NonFiniteValue { index } => {
114 write!(f, "geoid grid sample {index} is not finite")
115 }
116 Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
117 }
118 }
119}
120
121impl std::error::Error for GeoidError {}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum ProjVgridshiftError {
126 NonFiniteCoordinate {
128 field: &'static str,
130 },
131 CoordinateOutsideGrid {
133 field: &'static str,
135 },
136}
137
138impl core::fmt::Display for ProjVgridshiftError {
139 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140 match self {
141 Self::NonFiniteCoordinate { field } => {
142 write!(f, "PROJ vertical-grid {field} coordinate is not finite")
143 }
144 Self::CoordinateOutsideGrid { field } => {
145 write!(
146 f,
147 "PROJ vertical-grid {field} coordinate is outside the grid"
148 )
149 }
150 }
151 }
152}
153
154impl std::error::Error for ProjVgridshiftError {}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum ProjVgridshiftArithmetic {
165 SeparateMultiplyAdd,
170 FusedMultiplyAdd,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum Egm2008GridSpacing {
184 OneMinute,
186 TwoPointFiveMinute,
188}
189
190impl Egm2008GridSpacing {
191 pub fn arc_minutes(self) -> f64 {
193 match self {
194 Self::OneMinute => 1.0,
195 Self::TwoPointFiveMinute => 2.5,
196 }
197 }
198
199 pub fn degrees(self) -> f64 {
201 self.arc_minutes() / 60.0
202 }
203
204 pub fn global_dimensions(self) -> (usize, usize) {
206 match self {
207 Self::OneMinute => (EGM2008_1_MIN_N_LAT, EGM2008_1_MIN_N_LON),
208 Self::TwoPointFiveMinute => (EGM2008_2P5_MIN_N_LAT, EGM2008_2P5_MIN_N_LON),
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq)]
222pub struct Egm2008RasterWindow {
223 spacing: Egm2008GridSpacing,
224 lat_min_deg: f64,
225 lon_min_deg: f64,
226 n_lat: usize,
227 n_lon: usize,
228}
229
230impl Egm2008RasterWindow {
231 pub fn new(
239 spacing: Egm2008GridSpacing,
240 lat_min_deg: f64,
241 lon_min_deg: f64,
242 n_lat: usize,
243 n_lon: usize,
244 ) -> Result<Self, GeoidError> {
245 if n_lat == 0 || n_lon == 0 {
246 return Err(GeoidError::InvalidDimensions {
247 expected: 1,
248 found: 0,
249 });
250 }
251 if !lat_min_deg.is_finite() {
252 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
253 }
254 if !lon_min_deg.is_finite() {
255 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
256 }
257 let d = spacing.degrees();
258 let lat_max_deg = lat_min_deg + (n_lat as f64 - 1.0) * d;
259 if lat_min_deg < -90.0 - 1.0e-12 || lat_max_deg > 90.0 + 1.0e-12 {
260 return Err(GeoidError::Parse {
261 reason: format!(
262 "EGM2008 latitude window [{lat_min_deg}, {lat_max_deg}] exceeds [-90, 90]"
263 ),
264 });
265 }
266 let lon_span_deg = n_lon as f64 * d;
267 if lon_span_deg > 360.0 + 1.0e-12 {
268 return Err(GeoidError::Parse {
269 reason: format!("EGM2008 longitude span {lon_span_deg} exceeds 360 degrees"),
270 });
271 }
272 Ok(Self {
273 spacing,
274 lat_min_deg,
275 lon_min_deg,
276 n_lat,
277 n_lon,
278 })
279 }
280
281 pub fn global(spacing: Egm2008GridSpacing) -> Self {
283 let (n_lat, n_lon) = spacing.global_dimensions();
284 Self::new(spacing, -90.0, 0.0, n_lat, n_lon)
285 .expect("EGM2008 global raster dimensions are valid")
286 }
287
288 pub fn spacing(self) -> Egm2008GridSpacing {
290 self.spacing
291 }
292
293 pub fn lat_min_deg(self) -> f64 {
295 self.lat_min_deg
296 }
297
298 pub fn lon_min_deg(self) -> f64 {
300 self.lon_min_deg
301 }
302
303 pub fn n_lat(self) -> usize {
305 self.n_lat
306 }
307
308 pub fn n_lon(self) -> usize {
310 self.n_lon
311 }
312}
313
314#[derive(Debug, Clone, PartialEq)]
326pub struct GeoidGrid {
327 lat_min_deg: f64,
328 lon_min_deg: f64,
329 dlat_deg: f64,
330 dlon_deg: f64,
331 n_lat: usize,
332 n_lon: usize,
333 values_m: Vec<f64>,
334}
335
336impl GeoidGrid {
337 pub fn new(
344 lat_min_deg: f64,
345 lon_min_deg: f64,
346 dlat_deg: f64,
347 dlon_deg: f64,
348 n_lat: usize,
349 n_lon: usize,
350 values_m: Vec<f64>,
351 ) -> Result<Self, GeoidError> {
352 if n_lat == 0 || n_lon == 0 {
353 return Err(GeoidError::InvalidDimensions {
354 expected: 1,
355 found: 0,
356 });
357 }
358 let expected = n_lat * n_lon;
359 if values_m.len() != expected {
360 return Err(GeoidError::InvalidDimensions {
361 expected,
362 found: values_m.len(),
363 });
364 }
365 if !lat_min_deg.is_finite() {
366 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
367 }
368 if !lon_min_deg.is_finite() {
369 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
370 }
371 if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
372 return Err(GeoidError::InvalidSpacing { field: "dlat" });
373 }
374 if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
375 return Err(GeoidError::InvalidSpacing { field: "dlon" });
376 }
377 for (index, value) in values_m.iter().enumerate() {
378 if !value.is_finite() {
379 return Err(GeoidError::NonFiniteValue { index });
380 }
381 }
382 Ok(Self {
383 lat_min_deg,
384 lon_min_deg,
385 dlat_deg,
386 dlon_deg,
387 n_lat,
388 n_lon,
389 values_m,
390 })
391 }
392
393 pub fn from_text(text: &str) -> Result<Self, GeoidError> {
411 let mut tokens = text
412 .lines()
413 .map(|line| line.split('#').next().unwrap_or(""))
414 .flat_map(str::split_whitespace);
415
416 let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
417 let token = tokens.next().ok_or_else(|| GeoidError::Parse {
418 reason: format!("missing header field {field}"),
419 })?;
420 token.parse::<f64>().map_err(|_| GeoidError::Parse {
421 reason: format!("header field {field} is not a number: {token:?}"),
422 })
423 };
424
425 let lat_min_deg = next_field("lat_min")?;
426 let lon_min_deg = next_field("lon_min")?;
427 let dlat_deg = next_field("dlat")?;
428 let dlon_deg = next_field("dlon")?;
429 let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
430 let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
431
432 let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
433 reason: "n_lat * n_lon overflows".to_string(),
434 })?;
435 let mut values_m = Vec::with_capacity(expected);
436 for token in tokens {
437 let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
438 reason: format!("sample is not a number: {token:?}"),
439 })?;
440 values_m.push(value);
441 }
442
443 Self::new(
444 lat_min_deg,
445 lon_min_deg,
446 dlat_deg,
447 dlon_deg,
448 n_lat,
449 n_lon,
450 values_m,
451 )
452 }
453
454 pub fn from_egm96_dac(bytes: &[u8]) -> Result<Self, GeoidError> {
479 let expected = EGM96_DAC_N_LAT * EGM96_DAC_N_LON * 2;
480 if bytes.len() != expected {
481 return Err(GeoidError::Parse {
482 reason: format!(
483 "EGM96 WW15MGH.DAC must be {expected} bytes ({EGM96_DAC_N_LAT} x {EGM96_DAC_N_LON} big-endian int16), got {}",
484 bytes.len()
485 ),
486 });
487 }
488 let mut values_m = vec![0.0f64; EGM96_DAC_N_LAT * EGM96_DAC_N_LON];
489 for i in 0..EGM96_DAC_N_LAT {
490 let src_row = EGM96_DAC_N_LAT - 1 - i;
493 for c in 0..EGM96_DAC_N_LON {
494 let off = (src_row * EGM96_DAC_N_LON + c) * 2;
495 let cm = i16::from_be_bytes([bytes[off], bytes[off + 1]]);
496 values_m[i * EGM96_DAC_N_LON + c] = f64::from(cm) / 100.0;
497 }
498 }
499 Self::new(
500 -90.0,
501 0.0,
502 0.25,
503 0.25,
504 EGM96_DAC_N_LAT,
505 EGM96_DAC_N_LON,
506 values_m,
507 )
508 }
509
510 pub fn from_proj_egm96_gtx(bytes: &[u8]) -> Result<Self, GeoidError> {
525 let expected =
526 PROJ_EGM96_GTX_HEADER_BYTES + PROJ_EGM96_GTX_N_LAT * PROJ_EGM96_GTX_N_LON * 4;
527 if bytes.len() != expected {
528 return Err(GeoidError::Parse {
529 reason: format!(
530 "PROJ egm96_15.gtx must be {expected} bytes, got {}",
531 bytes.len()
532 ),
533 });
534 }
535
536 let south_deg = read_be_f64(bytes, 0);
537 let west_deg = read_be_f64(bytes, 8);
538 let dlat_deg = read_be_f64(bytes, 16);
539 let dlon_deg = read_be_f64(bytes, 24);
540 let n_lat = read_be_i32(bytes, 32);
541 let n_lon = read_be_i32(bytes, 36);
542 if south_deg.to_bits() != (-90.0f64).to_bits()
543 || west_deg.to_bits() != (-180.0f64).to_bits()
544 || dlat_deg.to_bits() != 0.25f64.to_bits()
545 || dlon_deg.to_bits() != 0.25f64.to_bits()
546 || n_lat != PROJ_EGM96_GTX_N_LAT as i32
547 || n_lon != PROJ_EGM96_GTX_N_LON as i32
548 {
549 return Err(GeoidError::Parse {
550 reason: format!(
551 "PROJ egm96_15.gtx header mismatch: south={south_deg}, west={west_deg}, dlat={dlat_deg}, dlon={dlon_deg}, rows={n_lat}, columns={n_lon}"
552 ),
553 });
554 }
555
556 let mut values_m = Vec::with_capacity(PROJ_EGM96_GTX_N_LAT * PROJ_EGM96_GTX_N_LON);
557 for chunk in bytes[PROJ_EGM96_GTX_HEADER_BYTES..].as_chunks::<4>().0 {
558 let value = f32::from_be_bytes(*chunk);
559 if !value.is_finite() {
560 return Err(GeoidError::NonFiniteValue {
561 index: values_m.len(),
562 });
563 }
564 values_m.push(f64::from(value));
565 }
566
567 Self::new(
568 south_deg,
569 west_deg,
570 dlat_deg,
571 dlon_deg,
572 PROJ_EGM96_GTX_N_LAT,
573 PROJ_EGM96_GTX_N_LON,
574 values_m,
575 )
576 }
577
578 pub fn from_egm2008_raster(
590 bytes: &[u8],
591 spacing: Egm2008GridSpacing,
592 ) -> Result<Self, GeoidError> {
593 Self::from_egm2008_raster_window(bytes, Egm2008RasterWindow::global(spacing))
594 }
595
596 pub fn from_egm2008_raster_window(
604 bytes: &[u8],
605 window: Egm2008RasterWindow,
606 ) -> Result<Self, GeoidError> {
607 let values_m = parse_egm2008_raster_values(bytes, window)?;
608 Self::new(
609 window.lat_min_deg,
610 window.lon_min_deg,
611 window.spacing.degrees(),
612 window.spacing.degrees(),
613 window.n_lat,
614 window.n_lon,
615 values_m,
616 )
617 }
618
619 fn is_global_longitude(&self) -> bool {
622 ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
623 || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
624 }
625
626 pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
629 self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
630 }
631
632 pub fn undulation_proj_rad(
645 &self,
646 lat_rad: f64,
647 lon_rad: f64,
648 arithmetic: ProjVgridshiftArithmetic,
649 ) -> Result<f64, ProjVgridshiftError> {
650 if !lat_rad.is_finite() {
651 return Err(ProjVgridshiftError::NonFiniteCoordinate { field: "latitude" });
652 }
653 if !lon_rad.is_finite() {
654 return Err(ProjVgridshiftError::NonFiniteCoordinate { field: "longitude" });
655 }
656
657 let west = self.lon_min_deg * PROJ_DEG_TO_RAD;
658 let south = self.lat_min_deg * PROJ_DEG_TO_RAD;
659 let res_x = self.dlon_deg * PROJ_DEG_TO_RAD;
660 let res_y = self.dlat_deg * PROJ_DEG_TO_RAD;
661 let east = (self.lon_min_deg + self.dlon_deg * (self.n_lon as f64 - 1.0)) * PROJ_DEG_TO_RAD;
662 let north =
663 (self.lat_min_deg + self.dlat_deg * (self.n_lat as f64 - 1.0)) * PROJ_DEG_TO_RAD;
664 let inv_res_x = 1.0 / res_x;
665 let inv_res_y = 1.0 / res_y;
666 let full_world_longitude = east - west + res_x >= 2.0 * core::f64::consts::PI - 1.0e-10;
667
668 if lat_rad < south || lat_rad > north {
669 return Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" });
670 }
671
672 let longitude_delta = lon_rad - west;
673 let mut grid_x = longitude_delta * inv_res_x;
674 if full_world_longitude && !grid_x.is_finite() {
675 grid_x = longitude_delta.rem_euclid(2.0 * core::f64::consts::PI) * inv_res_x;
676 }
677 if lon_rad < west {
678 if full_world_longitude {
679 let width = self.n_lon as f64;
680 grid_x = ((grid_x + width) % width + width) % width;
681 } else {
682 grid_x = (lon_rad + 2.0 * core::f64::consts::PI - west) * inv_res_x;
683 }
684 } else if lon_rad > east {
685 if full_world_longitude {
686 let width = self.n_lon as f64;
687 grid_x = ((grid_x + width) % width + width) % width;
688 } else {
689 grid_x = (lon_rad - 2.0 * core::f64::consts::PI - west) * inv_res_x;
690 }
691 }
692 let mut grid_y = (lat_rad - south) * inv_res_y;
693
694 let max_grid_x = if full_world_longitude {
695 self.n_lon as f64
696 } else {
697 self.n_lon as f64 - 1.0
698 };
699 if !grid_x.is_finite() || grid_x < 0.0 || grid_x > max_grid_x {
700 return Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "longitude" });
701 }
702 if !grid_y.is_finite() || grid_y < 0.0 || grid_y > self.n_lat as f64 - 1.0 {
703 return Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" });
704 }
705
706 let grid_ix = grid_x.floor() as usize;
707 let grid_iy = grid_y.floor() as usize;
708 if grid_ix >= self.n_lon {
709 return Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "longitude" });
710 }
711 if grid_iy >= self.n_lat {
712 return Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" });
713 }
714 grid_x -= grid_ix as f64;
715 grid_y -= grid_iy as f64;
716
717 let grid_ix2 = if grid_ix + 1 >= self.n_lon {
718 if full_world_longitude {
719 0
720 } else {
721 self.n_lon - 1
722 }
723 } else {
724 grid_ix + 1
725 };
726 let grid_iy2 = (grid_iy + 1).min(self.n_lat - 1);
727
728 let value_a = self.sample(grid_iy, grid_ix);
729 let value_b = self.sample(grid_iy, grid_ix2);
730 let value_c = self.sample(grid_iy2, grid_ix);
731 let value_d = self.sample(grid_iy2, grid_ix2);
732
733 let grid_x_y = grid_x * grid_y;
734 let weight_a = 1.0 - grid_x - grid_y + grid_x_y;
735 let mut value = value_a * weight_a;
736 let weight_b = grid_x - grid_x_y;
737 let weight_c = grid_y - grid_x_y;
738 let weight_d = grid_x_y;
739 match arithmetic {
740 ProjVgridshiftArithmetic::SeparateMultiplyAdd => {
741 value += value_b * weight_b;
742 value += value_c * weight_c;
743 value += value_d * weight_d;
744 }
745 ProjVgridshiftArithmetic::FusedMultiplyAdd => {
746 value = value_b.mul_add(weight_b, value);
747 value = value_c.mul_add(weight_c, value);
748 value = value_d.mul_add(weight_d, value);
749 }
750 }
751 Ok(value)
752 }
753
754 pub fn undulations_rad(&self, points_rad: &[(f64, f64)]) -> Vec<f64> {
760 points_rad
761 .iter()
762 .map(|&(lat_rad, lon_rad)| self.undulation_rad(lat_rad, lon_rad))
763 .collect()
764 }
765
766 pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
769 let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
770 let (i0, i1, ty) = self.lat_bracket(lat);
771
772 let (j0, j1, tx) = self.lon_bracket(lon_deg);
773
774 let v00 = self.sample(i0, j0);
775 let v01 = self.sample(i0, j1);
776 let v10 = self.sample(i1, j0);
777 let v11 = self.sample(i1, j1);
778
779 let bottom = v00 + (v01 - v00) * tx;
780 let top = v10 + (v11 - v10) * tx;
781 bottom + (top - bottom) * ty
782 }
783
784 pub fn undulations_deg(&self, points_deg: &[(f64, f64)]) -> Vec<f64> {
790 points_deg
791 .iter()
792 .map(|&(lat_deg, lon_deg)| self.undulation_deg(lat_deg, lon_deg))
793 .collect()
794 }
795
796 pub fn orthometric_height_rad(
800 &self,
801 ellipsoidal_height_m: f64,
802 lat_rad: f64,
803 lon_rad: f64,
804 ) -> f64 {
805 ellipsoidal_height_m - self.undulation_rad(lat_rad, lon_rad)
806 }
807
808 pub fn ellipsoidal_height_rad(
812 &self,
813 orthometric_height_m: f64,
814 lat_rad: f64,
815 lon_rad: f64,
816 ) -> f64 {
817 orthometric_height_m + self.undulation_rad(lat_rad, lon_rad)
818 }
819
820 pub fn orthometric_height_deg(
824 &self,
825 ellipsoidal_height_m: f64,
826 lat_deg: f64,
827 lon_deg: f64,
828 ) -> f64 {
829 ellipsoidal_height_m - self.undulation_deg(lat_deg, lon_deg)
830 }
831
832 pub fn ellipsoidal_height_deg(
836 &self,
837 orthometric_height_m: f64,
838 lat_deg: f64,
839 lon_deg: f64,
840 ) -> f64 {
841 orthometric_height_m + self.undulation_deg(lat_deg, lon_deg)
842 }
843
844 fn lat_max_deg(&self) -> f64 {
845 self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
846 }
847
848 fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
850 if self.n_lat == 1 {
851 return (0, 0, 0.0);
852 }
853 let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
854 let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
855 let i0 = (pos.floor() as usize).min(self.n_lat - 2);
856 (i0, i0 + 1, pos - i0 as f64)
857 }
858
859 fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
862 if self.n_lon == 1 {
863 return (0, 0, 0.0);
864 }
865 let lon = normalize_longitude_deg(lon_deg);
866 if self.is_global_longitude() {
867 let span = self.n_lon as f64 * self.dlon_deg;
868 let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
869 if offset >= span {
871 offset -= span;
872 }
873 let pos = offset / self.dlon_deg;
874 let j0 = (pos.floor() as usize) % self.n_lon;
875 let j1 = (j0 + 1) % self.n_lon;
876 (j0, j1, pos - pos.floor())
877 } else {
878 let pos =
879 ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
880 let j0 = (pos.floor() as usize).min(self.n_lon - 2);
881 (j0, j0 + 1, pos - j0 as f64)
882 }
883 }
884
885 fn sample(&self, i: usize, j: usize) -> f64 {
886 self.values_m[i * self.n_lon + j]
887 }
888}
889
890fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
892 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
893 return Err(GeoidError::Parse {
894 reason: format!("{field} must be a positive integer, got {value}"),
895 });
896 }
897 Ok(value as usize)
898}
899
900fn normalize_longitude_deg(lon_deg: f64) -> f64 {
902 let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
903 if wrapped >= 180.0 {
905 wrapped - 360.0
906 } else {
907 wrapped
908 }
909}
910
911pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
919 builtin_grid().undulation_rad(lat_rad, lon_rad)
920}
921
922pub fn geoid_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
928 builtin_grid().undulations_rad(points_rad)
929}
930
931pub fn geoid_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
936 builtin_grid().undulations_deg(points_deg)
937}
938
939pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
945 ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
946}
947
948pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
954 orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
955}
956
957pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
965 ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
966}
967
968pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
976 orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
977}
978
979const EGM96_DAC_N_LAT: usize = 721;
981const EGM96_DAC_N_LON: usize = 1440;
983
984const PROJ_EGM96_GTX_HEADER_BYTES: usize = 40;
986const PROJ_EGM96_GTX_N_LAT: usize = 721;
988const PROJ_EGM96_GTX_N_LON: usize = 1440;
990const PROJ_DEG_TO_RAD: f64 = 0.017453292519943296;
992
993fn read_be_f64(bytes: &[u8], offset: usize) -> f64 {
994 f64::from_be_bytes(
995 bytes[offset..offset + 8]
996 .try_into()
997 .expect("validated GTX header length"),
998 )
999}
1000
1001fn read_be_i32(bytes: &[u8], offset: usize) -> i32 {
1002 i32::from_be_bytes(
1003 bytes[offset..offset + 4]
1004 .try_into()
1005 .expect("validated GTX header length"),
1006 )
1007}
1008
1009const EGM2008_1_MIN_N_LAT: usize = 10801;
1011const EGM2008_1_MIN_N_LON: usize = 21600;
1013const EGM2008_2P5_MIN_N_LAT: usize = 4321;
1015const EGM2008_2P5_MIN_N_LON: usize = 8640;
1017
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019enum RasterEndian {
1020 Little,
1021 Big,
1022}
1023
1024impl RasterEndian {
1025 fn read_u32(self, bytes: [u8; 4]) -> u32 {
1026 match self {
1027 Self::Little => u32::from_le_bytes(bytes),
1028 Self::Big => u32::from_be_bytes(bytes),
1029 }
1030 }
1031
1032 fn read_f32(self, bytes: [u8; 4]) -> f32 {
1033 match self {
1034 Self::Little => f32::from_le_bytes(bytes),
1035 Self::Big => f32::from_be_bytes(bytes),
1036 }
1037 }
1038}
1039
1040fn parse_egm2008_raster_values(
1041 bytes: &[u8],
1042 window: Egm2008RasterWindow,
1043) -> Result<Vec<f64>, GeoidError> {
1044 let row_value_bytes = window
1045 .n_lon
1046 .checked_mul(4)
1047 .ok_or_else(|| GeoidError::Parse {
1048 reason: "EGM2008 row byte count overflows".to_string(),
1049 })?;
1050 let row_record_bytes = row_value_bytes
1051 .checked_add(8)
1052 .ok_or_else(|| GeoidError::Parse {
1053 reason: "EGM2008 record byte count overflows".to_string(),
1054 })?;
1055 let expected = window
1056 .n_lat
1057 .checked_mul(row_record_bytes)
1058 .ok_or_else(|| GeoidError::Parse {
1059 reason: "EGM2008 raster byte count overflows".to_string(),
1060 })?;
1061 if bytes.len() != expected {
1062 return Err(GeoidError::Parse {
1063 reason: format!(
1064 "EGM2008 raster window must be {expected} bytes ({} x {} REAL*4 row records), got {}",
1065 window.n_lat,
1066 window.n_lon,
1067 bytes.len()
1068 ),
1069 });
1070 }
1071
1072 let row_marker = u32::try_from(row_value_bytes).map_err(|_| GeoidError::Parse {
1073 reason: "EGM2008 row marker exceeds u32".to_string(),
1074 })?;
1075 let endian = detect_egm2008_endian(bytes, row_marker)?;
1076 let mut values_m = vec![0.0f64; window.n_lat * window.n_lon];
1077 for src_row in 0..window.n_lat {
1078 let row_off = src_row * row_record_bytes;
1079 let start_marker = endian.read_u32([
1080 bytes[row_off],
1081 bytes[row_off + 1],
1082 bytes[row_off + 2],
1083 bytes[row_off + 3],
1084 ]);
1085 let end_off = row_off + 4 + row_value_bytes;
1086 let end_marker = endian.read_u32([
1087 bytes[end_off],
1088 bytes[end_off + 1],
1089 bytes[end_off + 2],
1090 bytes[end_off + 3],
1091 ]);
1092 if start_marker != row_marker || end_marker != row_marker {
1093 return Err(GeoidError::Parse {
1094 reason: format!(
1095 "EGM2008 record {src_row} marker mismatch: start {start_marker}, end {end_marker}, expected {row_marker}"
1096 ),
1097 });
1098 }
1099 let dst_row = window.n_lat - 1 - src_row;
1100 for col in 0..window.n_lon {
1101 let sample_off = row_off + 4 + col * 4;
1102 let value = endian.read_f32([
1103 bytes[sample_off],
1104 bytes[sample_off + 1],
1105 bytes[sample_off + 2],
1106 bytes[sample_off + 3],
1107 ]);
1108 let index = dst_row * window.n_lon + col;
1109 if !value.is_finite() {
1110 return Err(GeoidError::NonFiniteValue { index });
1111 }
1112 values_m[index] = f64::from(value);
1113 }
1114 }
1115 Ok(values_m)
1116}
1117
1118fn detect_egm2008_endian(bytes: &[u8], row_marker: u32) -> Result<RasterEndian, GeoidError> {
1119 if bytes.len() < 4 {
1120 return Err(GeoidError::Parse {
1121 reason: "EGM2008 raster is too short for a record marker".to_string(),
1122 });
1123 }
1124 let first = [bytes[0], bytes[1], bytes[2], bytes[3]];
1125 let little = u32::from_le_bytes(first) == row_marker;
1126 let big = u32::from_be_bytes(first) == row_marker;
1127 match (little, big) {
1128 (true, false) => Ok(RasterEndian::Little),
1129 (false, true) => Ok(RasterEndian::Big),
1130 (true, true) => Err(GeoidError::Parse {
1131 reason: "EGM2008 record marker has ambiguous byte order".to_string(),
1132 }),
1133 (false, false) => Err(GeoidError::Parse {
1134 reason: format!("EGM2008 first record marker does not match {row_marker}"),
1135 }),
1136 }
1137}
1138
1139const EGM96_1DEG_N_LAT: usize = 181;
1141const EGM96_1DEG_N_LON: usize = 360;
1143
1144const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
1168
1169pub fn egm96_grid() -> &'static GeoidGrid {
1174 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
1175 GRID.get_or_init(|| {
1176 assert_eq!(
1177 EGM96_1DEG_BYTES.len(),
1178 EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
1179 "embedded EGM96 1-degree grid has the wrong byte length"
1180 );
1181 let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
1182 for (k, value) in values_m.iter_mut().enumerate() {
1183 let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
1184 *value = f64::from(cm) / 100.0;
1185 }
1186 GeoidGrid::new(
1187 -90.0,
1188 0.0,
1189 1.0,
1190 1.0,
1191 EGM96_1DEG_N_LAT,
1192 EGM96_1DEG_N_LON,
1193 values_m,
1194 )
1195 .expect("embedded EGM96 1-degree grid is well-formed")
1196 })
1197}
1198
1199pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
1209 egm96_grid().undulation_rad(lat_rad, lon_rad)
1210}
1211
1212pub fn egm96_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
1219 egm96_grid().undulations_rad(points_rad)
1220}
1221
1222pub fn egm96_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
1228 egm96_grid().undulations_deg(points_deg)
1229}
1230
1231fn builtin_grid() -> &'static GeoidGrid {
1233 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
1234 GRID.get_or_init(|| {
1235 GeoidGrid::new(
1236 -90.0,
1237 -180.0,
1238 30.0,
1239 30.0,
1240 BUILTIN_N_LAT,
1241 BUILTIN_N_LON,
1242 BUILTIN_VALUES_M.to_vec(),
1243 )
1244 .expect("built-in geoid grid is well-formed")
1245 })
1246}
1247
1248const BUILTIN_N_LAT: usize = 7; const BUILTIN_N_LON: usize = 13; #[rustfmt::skip]
1258const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
1259 -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,
1261 -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,
1263 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,
1265 -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,
1267 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,
1269 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,
1271 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,
1273];
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1317
1318 #[derive(Clone, Copy)]
1319 struct ProjGeoidFixture {
1320 lat_deg: f64,
1321 lon_deg: f64,
1322 undulation_m: f64,
1323 }
1324
1325 const EGM2008_NORCAL_CROP_BYTES: &[u8] =
1326 include_bytes!("../tests/fixtures/geoid/egm2008_25_norcal_crop.bin");
1327 const PROJ_EGM96_930_DENSE_BYTES: &[u8] =
1328 include_bytes!("../tests/fixtures/geoid/proj_egm96_930_dense.bin");
1329
1330 const PROJ_EGM96_FIXTURES: &[ProjGeoidFixture] = &[
1344 ProjGeoidFixture {
1345 lat_deg: 0.000000,
1346 lon_deg: 0.000000,
1347 undulation_m: 17.161579132080,
1348 },
1349 ProjGeoidFixture {
1350 lat_deg: 0.000000,
1351 lon_deg: 80.000000,
1352 undulation_m: -102.687904357910,
1353 },
1354 ProjGeoidFixture {
1355 lat_deg: 60.000000,
1356 lon_deg: -30.000000,
1357 undulation_m: 63.799266815186,
1358 },
1359 ProjGeoidFixture {
1360 lat_deg: 45.625000,
1361 lon_deg: 12.375000,
1362 undulation_m: 44.181870460510,
1363 },
1364 ProjGeoidFixture {
1365 lat_deg: 0.125000,
1366 lon_deg: 179.875000,
1367 undulation_m: 21.099070549011,
1368 },
1369 ProjGeoidFixture {
1370 lat_deg: 0.125000,
1371 lon_deg: -179.875000,
1372 undulation_m: 20.864660263062,
1373 },
1374 ProjGeoidFixture {
1375 lat_deg: -10.500000,
1376 lon_deg: 179.990000,
1377 undulation_m: 38.607539978027,
1378 },
1379 ProjGeoidFixture {
1380 lat_deg: -10.500000,
1381 lon_deg: -179.990000,
1382 undulation_m: 38.540365447998,
1383 },
1384 ProjGeoidFixture {
1385 lat_deg: 89.875000,
1386 lon_deg: 45.000000,
1387 undulation_m: 13.639517307281,
1388 },
1389 ProjGeoidFixture {
1390 lat_deg: -89.875000,
1391 lon_deg: 123.625000,
1392 undulation_m: -29.676423549652,
1393 },
1394 ProjGeoidFixture {
1395 lat_deg: 37.774900,
1396 lon_deg: -122.419400,
1397 undulation_m: -32.242452185586,
1398 },
1399 ];
1400
1401 const PROJ_EGM2008_FIXTURES: &[ProjGeoidFixture] = &[
1402 ProjGeoidFixture {
1403 lat_deg: 37.774900,
1404 lon_deg: -122.419400,
1405 undulation_m: -32.163558372373,
1406 },
1407 ProjGeoidFixture {
1408 lat_deg: 37.500000,
1409 lon_deg: -122.750000,
1410 undulation_m: -33.605857849121,
1411 },
1412 ProjGeoidFixture {
1413 lat_deg: 37.875000,
1414 lon_deg: -122.125000,
1415 undulation_m: -31.847370147705,
1416 },
1417 ProjGeoidFixture {
1418 lat_deg: 38.000000,
1419 lon_deg: -122.000000,
1420 undulation_m: -31.767843246460,
1421 },
1422 ProjGeoidFixture {
1423 lat_deg: 37.000000,
1424 lon_deg: -123.000000,
1425 undulation_m: -36.499370574951,
1426 },
1427 ];
1428
1429 const SPARSE_EGM96_DAC_NODES_CM: &[(f64, f64, i16)] = &[
1438 (-90.00, 123.50, -2953),
1439 (-90.00, 123.75, -2953),
1440 (-89.75, 123.50, -2982),
1441 (-89.75, 123.75, -2982),
1442 (-10.50, 179.75, 3919),
1443 (-10.50, 180.00, 3858),
1444 (-10.50, 180.25, 3751),
1445 (-10.25, 179.75, 3733),
1446 (-10.25, 180.00, 3697),
1447 (-10.25, 180.25, 3611),
1448 (0.00, 0.00, 1716),
1449 (0.00, 0.25, 1708),
1450 (0.00, 80.00, -10269),
1451 (0.00, 80.25, -10255),
1452 (0.00, 179.75, 2138),
1453 (0.00, 180.00, 2115),
1454 (0.00, 180.25, 2095),
1455 (0.25, 0.00, 1719),
1456 (0.25, 0.25, 1711),
1457 (0.25, 80.00, -10286),
1458 (0.25, 80.25, -10276),
1459 (0.25, 179.75, 2109),
1460 (0.25, 180.00, 2078),
1461 (0.25, 180.25, 2058),
1462 (37.75, 237.50, -3237),
1463 (37.75, 237.75, -3204),
1464 (38.00, 237.50, -3211),
1465 (38.00, 237.75, -3200),
1466 (45.50, 12.25, 4398),
1467 (45.50, 12.50, 4355),
1468 (45.75, 12.25, 4498),
1469 (45.75, 12.50, 4421),
1470 (60.00, 330.00, 6380),
1471 (60.00, 330.25, 6400),
1472 (60.25, 330.00, 6365),
1473 (60.25, 330.25, 6388),
1474 (89.75, 45.00, 1367),
1475 (89.75, 45.25, 1367),
1476 (90.00, 45.00, 1361),
1477 (90.00, 45.25, 1361),
1478 ];
1479
1480 fn sparse_egm96_dac_bytes() -> Vec<u8> {
1481 let mut bytes = vec![0u8; super::EGM96_DAC_N_LAT * super::EGM96_DAC_N_LON * 2];
1482 for &(lat_deg, lon_east_deg, cm) in SPARSE_EGM96_DAC_NODES_CM {
1483 let record = ((90.0 - lat_deg) / 0.25).round() as usize;
1484 let col = (lon_east_deg.rem_euclid(360.0) / 0.25).round() as usize;
1485 assert!(record < super::EGM96_DAC_N_LAT);
1486 assert!(col < super::EGM96_DAC_N_LON);
1487 let off = (record * super::EGM96_DAC_N_LON + col) * 2;
1488 bytes[off..off + 2].copy_from_slice(&cm.to_be_bytes());
1489 }
1490 bytes
1491 }
1492
1493 fn egm2008_norcal_window() -> Egm2008RasterWindow {
1494 Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 37.0, -123.0, 25, 25)
1495 .expect("EGM2008 crop window")
1496 }
1497
1498 fn egm2008_test_raster_bytes(
1499 window: Egm2008RasterWindow,
1500 little_endian: bool,
1501 value: impl Fn(usize, usize) -> f32,
1502 ) -> Vec<u8> {
1503 let row_value_bytes = window.n_lon() * 4;
1504 let mut bytes = Vec::with_capacity(window.n_lat() * (row_value_bytes + 8));
1505 for src_row in 0..window.n_lat() {
1506 if little_endian {
1507 bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1508 } else {
1509 bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1510 }
1511 for col in 0..window.n_lon() {
1512 let sample = value(src_row, col);
1513 if little_endian {
1514 bytes.extend_from_slice(&sample.to_le_bytes());
1515 } else {
1516 bytes.extend_from_slice(&sample.to_be_bytes());
1517 }
1518 }
1519 if little_endian {
1520 bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1521 } else {
1522 bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1523 }
1524 }
1525 bytes
1526 }
1527
1528 fn sparse_proj_egm96_gtx_from_dense_fixture() -> (Vec<u8>, usize, usize) {
1529 assert_eq!(&PROJ_EGM96_930_DENSE_BYTES[..8], b"SIDGEO93");
1530 let node_count = u32::from_be_bytes(
1531 PROJ_EGM96_930_DENSE_BYTES[8..12]
1532 .try_into()
1533 .expect("fixture node count"),
1534 ) as usize;
1535 let point_count = u32::from_be_bytes(
1536 PROJ_EGM96_930_DENSE_BYTES[12..16]
1537 .try_into()
1538 .expect("fixture point count"),
1539 ) as usize;
1540 let point_offset = 16 + node_count * 8;
1541 assert_eq!(
1542 PROJ_EGM96_930_DENSE_BYTES.len(),
1543 point_offset + point_count * 24
1544 );
1545
1546 let mut gtx = vec![
1547 0u8;
1548 super::PROJ_EGM96_GTX_HEADER_BYTES
1549 + super::PROJ_EGM96_GTX_N_LAT * super::PROJ_EGM96_GTX_N_LON * 4
1550 ];
1551 gtx[0..8].copy_from_slice(&(-90.0f64).to_be_bytes());
1552 gtx[8..16].copy_from_slice(&(-180.0f64).to_be_bytes());
1553 gtx[16..24].copy_from_slice(&0.25f64.to_be_bytes());
1554 gtx[24..32].copy_from_slice(&0.25f64.to_be_bytes());
1555 gtx[32..36].copy_from_slice(&(super::PROJ_EGM96_GTX_N_LAT as i32).to_be_bytes());
1556 gtx[36..40].copy_from_slice(&(super::PROJ_EGM96_GTX_N_LON as i32).to_be_bytes());
1557
1558 for record in PROJ_EGM96_930_DENSE_BYTES[16..point_offset]
1559 .as_chunks::<8>()
1560 .0
1561 {
1562 let index = u32::from_be_bytes(record[..4].try_into().expect("fixture node index"));
1563 let offset = super::PROJ_EGM96_GTX_HEADER_BYTES + index as usize * 4;
1564 gtx[offset..offset + 4].copy_from_slice(&record[4..8]);
1565 }
1566 (gtx, point_offset, point_count)
1567 }
1568
1569 #[test]
1570 fn builtin_returns_exact_node_values() {
1571 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
1573 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
1575 assert_eq!(
1577 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
1578 60.0
1579 );
1580 }
1581
1582 #[test]
1583 fn builtin_captures_major_geoid_features_by_sign() {
1584 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
1586 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
1587 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
1589 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
1590 }
1591
1592 #[test]
1593 fn bilinear_midpoint_is_the_corner_average() {
1594 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
1595 let center = grid.undulation_deg(5.0, 5.0);
1597 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
1598 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
1600 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
1601 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
1603 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
1604 }
1605
1606 #[test]
1607 fn global_grid_wraps_across_the_antimeridian() {
1608 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
1612 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
1613 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
1614 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
1616 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
1617 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
1619 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
1620 assert_eq!(plus, minus);
1621 assert_eq!(plus, -10.0);
1622 }
1623
1624 #[test]
1625 fn orthometric_height_subtracts_undulation() {
1626 let lat = 0.0;
1627 let lon = 0.0;
1628 let n = geoid_undulation(lat, lon);
1629 assert_eq!(n, 17.0);
1630 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
1632 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
1634 }
1635
1636 #[test]
1637 fn egm96_height_converters_use_the_egm96_undulation() {
1638 let lat = 37.0_f64.to_radians();
1642 let lon = (-122.0_f64).to_radians();
1643 let n = egm96_undulation(lat, lon);
1644 let h = 250.0;
1645 let big_h = egm96_orthometric_height_m(h, lat, lon);
1646 assert_eq!(big_h, h - n);
1647 assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
1648 assert_ne!(
1650 egm96_orthometric_height_m(h, lat, lon),
1651 orthometric_height_m(h, lat, lon)
1652 );
1653 }
1654
1655 #[test]
1656 fn batch_undulation_entries_match_scalar_lookup() {
1657 let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
1658 let got_deg = egm96_undulations_deg(&points_deg);
1659 let expected_deg: Vec<f64> = points_deg
1660 .iter()
1661 .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
1662 .collect();
1663 assert_eq!(got_deg, expected_deg);
1664
1665 let points_rad: Vec<(f64, f64)> = points_deg
1666 .iter()
1667 .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
1668 .collect();
1669 let got_rad = egm96_undulations_rad(&points_rad);
1670 let expected_rad: Vec<f64> = points_rad
1671 .iter()
1672 .map(|&(lat, lon)| egm96_undulation(lat, lon))
1673 .collect();
1674 assert_eq!(got_rad, expected_rad);
1675
1676 assert_eq!(
1677 geoid_undulations_deg(&points_deg),
1678 points_deg
1679 .iter()
1680 .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
1681 .collect::<Vec<_>>()
1682 );
1683 }
1684
1685 #[test]
1686 fn from_text_round_trips_a_grid() {
1687 let text = "\
1688# coarse 2x3 regional grid
1689# lat_min lon_min dlat dlon n_lat n_lon
169010.0 20.0 5.0 5.0 2 3
1691 1.0 2.0 3.0 # lat 10 row
1692 4.0 5.0 6.0 # lat 15 row
1693";
1694 let grid = GeoidGrid::from_text(text).expect("parse grid");
1695 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
1696 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
1697 let center = grid.undulation_deg(12.5, 22.5);
1699 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
1700 assert_eq!(
1702 grid.undulation_deg(10.0, 0.0),
1703 grid.undulation_deg(10.0, 20.0)
1704 );
1705 }
1706
1707 #[test]
1708 fn from_text_rejects_short_data() {
1709 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
1710 assert_eq!(
1711 GeoidGrid::from_text(text),
1712 Err(GeoidError::InvalidDimensions {
1713 expected: 4,
1714 found: 3
1715 })
1716 );
1717 }
1718
1719 #[test]
1720 fn from_egm2008_raster_window_decodes_little_and_big_endian_records() {
1721 let d = Egm2008GridSpacing::TwoPointFiveMinute.degrees();
1722 let window =
1723 Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 10.0, 20.0, 2, 3)
1724 .expect("EGM2008 test window");
1725 for little_endian in [true, false] {
1726 let bytes = egm2008_test_raster_bytes(window, little_endian, |src_row, col| {
1727 (src_row * 10 + col) as f32
1728 });
1729 let grid =
1730 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008");
1731 assert_eq!(grid.undulation_deg(10.0, 20.0), 10.0);
1732 assert!((grid.undulation_deg(10.0 + d, 20.0 + 2.0 * d) - 2.0).abs() <= 1.0e-12);
1733 assert!((grid.undulation_deg(10.0 + 0.5 * d, 20.0 + d) - 6.0).abs() <= 1.0e-12);
1734 }
1735 }
1736
1737 #[test]
1738 fn from_egm2008_raster_rejects_bad_record_layout() {
1739 assert!(matches!(
1740 GeoidGrid::from_egm2008_raster(
1741 EGM2008_NORCAL_CROP_BYTES,
1742 Egm2008GridSpacing::TwoPointFiveMinute,
1743 ),
1744 Err(GeoidError::Parse { .. })
1745 ));
1746
1747 let window = egm2008_norcal_window();
1748 let mut bytes = EGM2008_NORCAL_CROP_BYTES.to_vec();
1749 bytes[0] = 0;
1750 assert!(matches!(
1751 GeoidGrid::from_egm2008_raster_window(&bytes, window),
1752 Err(GeoidError::Parse { .. })
1753 ));
1754 }
1755
1756 #[test]
1757 fn egm2008_crop_matches_proj_oracle() {
1758 let grid = GeoidGrid::from_egm2008_raster_window(
1759 EGM2008_NORCAL_CROP_BYTES,
1760 egm2008_norcal_window(),
1761 )
1762 .expect("parse EGM2008 crop");
1763 for fixture in PROJ_EGM2008_FIXTURES {
1764 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1765 assert!(
1766 (got - fixture.undulation_m).abs() <= 0.005,
1767 "PROJ EGM2008 fixture ({}, {}): got {got}, want {}",
1768 fixture.lat_deg,
1769 fixture.lon_deg,
1770 fixture.undulation_m
1771 );
1772 }
1773 }
1774
1775 #[test]
1776 fn egm2008_regional_crop_clamps_grid_edges() {
1777 let grid = GeoidGrid::from_egm2008_raster_window(
1778 EGM2008_NORCAL_CROP_BYTES,
1779 egm2008_norcal_window(),
1780 )
1781 .expect("parse EGM2008 crop");
1782 assert_eq!(
1783 grid.undulation_deg(36.0, -124.0),
1784 grid.undulation_deg(37.0, -123.0)
1785 );
1786 assert_eq!(
1787 grid.undulation_deg(39.0, -121.0),
1788 grid.undulation_deg(38.0, -122.0)
1789 );
1790 }
1791
1792 #[test]
1793 fn egm2008_global_longitude_window_wraps_through_shared_kernel() {
1794 let spacing = Egm2008GridSpacing::TwoPointFiveMinute;
1795 let d = spacing.degrees();
1796 let (_, n_lon) = spacing.global_dimensions();
1797 let window = Egm2008RasterWindow::new(spacing, 0.0, 0.0, 2, n_lon)
1798 .expect("global-longitude EGM2008 window");
1799 let bytes = egm2008_test_raster_bytes(window, true, |_, col| {
1800 if col == 0 {
1801 100.0
1802 } else if col == n_lon - 1 {
1803 200.0
1804 } else {
1805 10.0
1806 }
1807 });
1808 let grid =
1809 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008 wrap");
1810
1811 assert_eq!(grid.undulation_deg(0.0, 360.0), 100.0);
1812 assert_eq!(grid.undulation_deg(0.0, -0.5 * d), 150.0);
1813 }
1814
1815 #[test]
1816 fn new_rejects_bad_inputs() {
1817 assert!(matches!(
1818 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
1819 Err(GeoidError::InvalidDimensions { .. })
1820 ));
1821 assert!(matches!(
1822 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
1823 Err(GeoidError::InvalidSpacing { field: "dlat" })
1824 ));
1825 assert!(matches!(
1826 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
1827 Err(GeoidError::NonFiniteValue { index: 1 })
1828 ));
1829 }
1830
1831 #[test]
1832 fn longitude_normalization_folds_into_half_open_interval() {
1833 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1834 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1835 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1836 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1837 }
1838
1839 #[test]
1844 fn egm96_grid_reproduces_genuine_nodes() {
1845 let nodes: [(f64, f64, f64); 5] = [
1847 (0.0, 0.0, 17.16), (0.0, 80.0, -102.69), (60.0, -30.0, 63.80), (-90.0, 0.0, -29.53), (90.0, 0.0, 13.61), ];
1853 for (lat, lon, want) in nodes {
1854 let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1855 assert!(
1856 (got - want).abs() <= 1.0e-9,
1857 "egm96 node ({lat},{lon}): got {got}, want {want}"
1858 );
1859 }
1860 }
1861
1862 #[test]
1873 fn egm96_grid_matches_published_checkpoint() {
1874 let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1875 let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1876 let published = 28.7068;
1877
1878 let egm96 = egm96_undulation(lat, lon);
1879 assert!(
1880 (egm96 - published).abs() < 0.5,
1881 "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1882 );
1883
1884 let coarse = geoid_undulation(lat, lon);
1887 assert!(
1888 (egm96 - published).abs() < (coarse - published).abs(),
1889 "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1890 );
1891 }
1892
1893 #[test]
1894 fn egm96_embedded_outputs_are_bit_pinned() {
1895 let fixtures = [
1896 (37.0_f64, -122.0_f64, 0xc040_accc_cccc_cccdu64),
1897 (37.5_f64, -122.5_f64, 0xc040_de66_6666_6666u64),
1898 (
1899 16.0 + 46.0 / 60.0 + 33.0 / 3600.0,
1900 -(3.0 + 34.0 / 3600.0),
1901 0x403c_acb4_79a8_1af4u64,
1902 ),
1903 (0.125_f64, -179.875_f64, 0x4034_cbf5_c28f_5c29u64),
1904 ];
1905 for (lat_deg, lon_deg, bits) in fixtures {
1906 let got = egm96_undulation(lat_deg.to_radians(), lon_deg.to_radians());
1907 assert_eq!(
1908 got.to_bits(),
1909 bits,
1910 "EGM96 bit pin ({lat_deg}, {lon_deg}) got {got}"
1911 );
1912 }
1913 }
1914
1915 #[test]
1920 fn from_egm96_dac_decodes_the_nga_layout() {
1921 let n_lat = super::EGM96_DAC_N_LAT;
1922 let n_lon = super::EGM96_DAC_N_LON;
1923 let cm = |record: usize, col: usize| -> i16 {
1925 ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1926 };
1927
1928 let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1929 for record in 0..n_lat {
1930 for col in 0..n_lon {
1931 bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1932 }
1933 }
1934 let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1935
1936 let mut values_m = vec![0.0f64; n_lat * n_lon];
1939 for i in 0..n_lat {
1940 let record = n_lat - 1 - i;
1941 for col in 0..n_lon {
1942 values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1943 }
1944 }
1945 let expected =
1946 GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1947 assert_eq!(parsed, expected);
1948
1949 assert!(matches!(
1951 GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1952 Err(GeoidError::Parse { .. })
1953 ));
1954 }
1955
1956 #[test]
1957 fn from_proj_egm96_gtx_rejects_wrong_layout_and_nonfinite_samples() {
1958 let (mut bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
1959 assert!(matches!(
1960 GeoidGrid::from_proj_egm96_gtx(&bytes[..bytes.len() - 4]),
1961 Err(GeoidError::Parse { .. })
1962 ));
1963
1964 bytes[16..24].copy_from_slice(&0.5f64.to_be_bytes());
1965 assert!(matches!(
1966 GeoidGrid::from_proj_egm96_gtx(&bytes),
1967 Err(GeoidError::Parse { .. })
1968 ));
1969
1970 bytes[16..24].copy_from_slice(&0.25f64.to_be_bytes());
1971 bytes[super::PROJ_EGM96_GTX_HEADER_BYTES..super::PROJ_EGM96_GTX_HEADER_BYTES + 4]
1972 .copy_from_slice(&f32::NAN.to_be_bytes());
1973 assert_eq!(
1974 GeoidGrid::from_proj_egm96_gtx(&bytes),
1975 Err(GeoidError::NonFiniteValue { index: 0 })
1976 );
1977 }
1978
1979 #[test]
1980 fn proj_930_egm96_fused_dense_sample_is_zero_ulp() {
1981 let (bytes, point_offset, point_count) = sparse_proj_egm96_gtx_from_dense_fixture();
1982 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
1983
1984 for (index, record) in PROJ_EGM96_930_DENSE_BYTES[point_offset..]
1985 .as_chunks::<24>()
1986 .0
1987 .iter()
1988 .enumerate()
1989 {
1990 let lon_rad = f64::from_bits(u64::from_be_bytes(
1991 record[..8].try_into().expect("fixture longitude"),
1992 ));
1993 let lat_rad = f64::from_bits(u64::from_be_bytes(
1994 record[8..16].try_into().expect("fixture latitude"),
1995 ));
1996 let expected_bits =
1997 u64::from_be_bytes(record[16..24].try_into().expect("fixture undulation"));
1998 let got = grid
1999 .undulation_proj_rad(lat_rad, lon_rad, ProjVgridshiftArithmetic::FusedMultiplyAdd)
2000 .expect("fixture coordinate is inside the grid");
2001 assert_eq!(
2002 got.to_bits(),
2003 expected_bits,
2004 "contracted PROJ 9.3.0 EGM96 point {index}/{point_count}: lat={lat_rad}, lon={lon_rad}, got={got}"
2005 );
2006 }
2007 assert_eq!(point_count, 13_051);
2008 }
2009
2010 #[test]
2011 fn proj_930_egm96_separate_multiply_add_bits_are_pinned() {
2012 let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2013 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2014
2015 let cases = [
2019 (
2020 0xbff9_21e9_072f_0bff,
2021 0x3fb4_1b28_2494_7d44,
2022 0xc03d_88b2_3abe_f2f0,
2023 ),
2024 (
2025 0xbfd9_21e9_072f_0bfc,
2026 0x4006_eef9_c9b9_5ed9,
2027 0x4049_ff99_e8a7_6f47,
2028 ),
2029 (
2030 0x3fd9_21e9_072f_0c01,
2031 0x4004_6b94_c526_cf33,
2032 0x403d_018d_8044_d991,
2033 ),
2034 (
2035 0x3fef_6a63_48fa_cf01,
2036 0x3ffb_a557_324c_2c33,
2037 0xc044_4b8e_0e1f_a00c,
2038 ),
2039 (
2040 0x3ff9_21e9_072f_0bff,
2041 0x4009_21f2_2db9_9c8b,
2042 0x402b_362a_4459_31d0,
2043 ),
2044 ];
2045 for (lat_bits, lon_bits, expected_bits) in cases {
2046 let got = grid
2047 .undulation_proj_rad(
2048 f64::from_bits(lat_bits),
2049 f64::from_bits(lon_bits),
2050 ProjVgridshiftArithmetic::SeparateMultiplyAdd,
2051 )
2052 .expect("fixture coordinate is inside the grid");
2053 assert_eq!(got.to_bits(), expected_bits);
2054 }
2055 }
2056
2057 #[test]
2058 fn proj_lookup_rejects_invalid_coordinates_without_panicking() {
2059 let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2060 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2061 let arithmetic = ProjVgridshiftArithmetic::FusedMultiplyAdd;
2062
2063 assert_eq!(
2064 grid.undulation_proj_rad(f64::NAN, 0.0, arithmetic),
2065 Err(ProjVgridshiftError::NonFiniteCoordinate { field: "latitude" })
2066 );
2067 assert_eq!(
2068 grid.undulation_proj_rad(0.0, f64::INFINITY, arithmetic),
2069 Err(ProjVgridshiftError::NonFiniteCoordinate { field: "longitude" })
2070 );
2071 assert_eq!(
2072 grid.undulation_proj_rad(-91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2073 Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2074 );
2075 assert_eq!(
2076 grid.undulation_proj_rad(91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2077 Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2078 );
2079 assert!(grid
2080 .undulation_proj_rad(0.0, f64::MAX, arithmetic)
2081 .expect("full-world grids wrap every finite longitude")
2082 .is_finite());
2083 }
2084
2085 #[test]
2086 fn egm96_dac_sparse_fixture_matches_proj_oracle() {
2087 let bytes = sparse_egm96_dac_bytes();
2088 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2089 for fixture in PROJ_EGM96_FIXTURES {
2090 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2091 assert!(
2092 (got - fixture.undulation_m).abs() <= 0.005,
2093 "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
2094 fixture.lat_deg,
2095 fixture.lon_deg,
2096 fixture.undulation_m
2097 );
2098 }
2099 }
2100
2101 #[test]
2102 fn geoid_grid_height_conversions_pin_sign_convention() {
2103 let bytes = sparse_egm96_dac_bytes();
2104 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2105 for fixture in PROJ_EGM96_FIXTURES {
2106 let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2107 let h = 250.0;
2108 let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
2109 assert_eq!(orthometric, h - n);
2110 assert_eq!(
2111 grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
2112 orthometric + n
2113 );
2114
2115 let lat_rad = fixture.lat_deg.to_radians();
2116 let lon_rad = fixture.lon_deg.to_radians();
2117 assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
2118 assert!(
2119 (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
2120 .abs()
2121 <= 1.0e-12
2122 );
2123 }
2124 }
2125}