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..].chunks_exact(4) {
558 let value = f32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
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].chunks_exact(8) {
1559 let index = u32::from_be_bytes(record[..4].try_into().expect("fixture node index"));
1560 let offset = super::PROJ_EGM96_GTX_HEADER_BYTES + index as usize * 4;
1561 gtx[offset..offset + 4].copy_from_slice(&record[4..8]);
1562 }
1563 (gtx, point_offset, point_count)
1564 }
1565
1566 #[test]
1567 fn builtin_returns_exact_node_values() {
1568 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
1570 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
1572 assert_eq!(
1574 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
1575 60.0
1576 );
1577 }
1578
1579 #[test]
1580 fn builtin_captures_major_geoid_features_by_sign() {
1581 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
1583 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
1584 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
1586 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
1587 }
1588
1589 #[test]
1590 fn bilinear_midpoint_is_the_corner_average() {
1591 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
1592 let center = grid.undulation_deg(5.0, 5.0);
1594 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
1595 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
1597 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
1598 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
1600 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
1601 }
1602
1603 #[test]
1604 fn global_grid_wraps_across_the_antimeridian() {
1605 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
1609 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
1610 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
1611 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
1613 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
1614 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
1616 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
1617 assert_eq!(plus, minus);
1618 assert_eq!(plus, -10.0);
1619 }
1620
1621 #[test]
1622 fn orthometric_height_subtracts_undulation() {
1623 let lat = 0.0;
1624 let lon = 0.0;
1625 let n = geoid_undulation(lat, lon);
1626 assert_eq!(n, 17.0);
1627 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
1629 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
1631 }
1632
1633 #[test]
1634 fn egm96_height_converters_use_the_egm96_undulation() {
1635 let lat = 37.0_f64.to_radians();
1639 let lon = (-122.0_f64).to_radians();
1640 let n = egm96_undulation(lat, lon);
1641 let h = 250.0;
1642 let big_h = egm96_orthometric_height_m(h, lat, lon);
1643 assert_eq!(big_h, h - n);
1644 assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
1645 assert_ne!(
1647 egm96_orthometric_height_m(h, lat, lon),
1648 orthometric_height_m(h, lat, lon)
1649 );
1650 }
1651
1652 #[test]
1653 fn batch_undulation_entries_match_scalar_lookup() {
1654 let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
1655 let got_deg = egm96_undulations_deg(&points_deg);
1656 let expected_deg: Vec<f64> = points_deg
1657 .iter()
1658 .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
1659 .collect();
1660 assert_eq!(got_deg, expected_deg);
1661
1662 let points_rad: Vec<(f64, f64)> = points_deg
1663 .iter()
1664 .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
1665 .collect();
1666 let got_rad = egm96_undulations_rad(&points_rad);
1667 let expected_rad: Vec<f64> = points_rad
1668 .iter()
1669 .map(|&(lat, lon)| egm96_undulation(lat, lon))
1670 .collect();
1671 assert_eq!(got_rad, expected_rad);
1672
1673 assert_eq!(
1674 geoid_undulations_deg(&points_deg),
1675 points_deg
1676 .iter()
1677 .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
1678 .collect::<Vec<_>>()
1679 );
1680 }
1681
1682 #[test]
1683 fn from_text_round_trips_a_grid() {
1684 let text = "\
1685# coarse 2x3 regional grid
1686# lat_min lon_min dlat dlon n_lat n_lon
168710.0 20.0 5.0 5.0 2 3
1688 1.0 2.0 3.0 # lat 10 row
1689 4.0 5.0 6.0 # lat 15 row
1690";
1691 let grid = GeoidGrid::from_text(text).expect("parse grid");
1692 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
1693 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
1694 let center = grid.undulation_deg(12.5, 22.5);
1696 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
1697 assert_eq!(
1699 grid.undulation_deg(10.0, 0.0),
1700 grid.undulation_deg(10.0, 20.0)
1701 );
1702 }
1703
1704 #[test]
1705 fn from_text_rejects_short_data() {
1706 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
1707 assert_eq!(
1708 GeoidGrid::from_text(text),
1709 Err(GeoidError::InvalidDimensions {
1710 expected: 4,
1711 found: 3
1712 })
1713 );
1714 }
1715
1716 #[test]
1717 fn from_egm2008_raster_window_decodes_little_and_big_endian_records() {
1718 let d = Egm2008GridSpacing::TwoPointFiveMinute.degrees();
1719 let window =
1720 Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 10.0, 20.0, 2, 3)
1721 .expect("EGM2008 test window");
1722 for little_endian in [true, false] {
1723 let bytes = egm2008_test_raster_bytes(window, little_endian, |src_row, col| {
1724 (src_row * 10 + col) as f32
1725 });
1726 let grid =
1727 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008");
1728 assert_eq!(grid.undulation_deg(10.0, 20.0), 10.0);
1729 assert!((grid.undulation_deg(10.0 + d, 20.0 + 2.0 * d) - 2.0).abs() <= 1.0e-12);
1730 assert!((grid.undulation_deg(10.0 + 0.5 * d, 20.0 + d) - 6.0).abs() <= 1.0e-12);
1731 }
1732 }
1733
1734 #[test]
1735 fn from_egm2008_raster_rejects_bad_record_layout() {
1736 assert!(matches!(
1737 GeoidGrid::from_egm2008_raster(
1738 EGM2008_NORCAL_CROP_BYTES,
1739 Egm2008GridSpacing::TwoPointFiveMinute,
1740 ),
1741 Err(GeoidError::Parse { .. })
1742 ));
1743
1744 let window = egm2008_norcal_window();
1745 let mut bytes = EGM2008_NORCAL_CROP_BYTES.to_vec();
1746 bytes[0] = 0;
1747 assert!(matches!(
1748 GeoidGrid::from_egm2008_raster_window(&bytes, window),
1749 Err(GeoidError::Parse { .. })
1750 ));
1751 }
1752
1753 #[test]
1754 fn egm2008_crop_matches_proj_oracle() {
1755 let grid = GeoidGrid::from_egm2008_raster_window(
1756 EGM2008_NORCAL_CROP_BYTES,
1757 egm2008_norcal_window(),
1758 )
1759 .expect("parse EGM2008 crop");
1760 for fixture in PROJ_EGM2008_FIXTURES {
1761 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1762 assert!(
1763 (got - fixture.undulation_m).abs() <= 0.005,
1764 "PROJ EGM2008 fixture ({}, {}): got {got}, want {}",
1765 fixture.lat_deg,
1766 fixture.lon_deg,
1767 fixture.undulation_m
1768 );
1769 }
1770 }
1771
1772 #[test]
1773 fn egm2008_regional_crop_clamps_grid_edges() {
1774 let grid = GeoidGrid::from_egm2008_raster_window(
1775 EGM2008_NORCAL_CROP_BYTES,
1776 egm2008_norcal_window(),
1777 )
1778 .expect("parse EGM2008 crop");
1779 assert_eq!(
1780 grid.undulation_deg(36.0, -124.0),
1781 grid.undulation_deg(37.0, -123.0)
1782 );
1783 assert_eq!(
1784 grid.undulation_deg(39.0, -121.0),
1785 grid.undulation_deg(38.0, -122.0)
1786 );
1787 }
1788
1789 #[test]
1790 fn egm2008_global_longitude_window_wraps_through_shared_kernel() {
1791 let spacing = Egm2008GridSpacing::TwoPointFiveMinute;
1792 let d = spacing.degrees();
1793 let (_, n_lon) = spacing.global_dimensions();
1794 let window = Egm2008RasterWindow::new(spacing, 0.0, 0.0, 2, n_lon)
1795 .expect("global-longitude EGM2008 window");
1796 let bytes = egm2008_test_raster_bytes(window, true, |_, col| {
1797 if col == 0 {
1798 100.0
1799 } else if col == n_lon - 1 {
1800 200.0
1801 } else {
1802 10.0
1803 }
1804 });
1805 let grid =
1806 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008 wrap");
1807
1808 assert_eq!(grid.undulation_deg(0.0, 360.0), 100.0);
1809 assert_eq!(grid.undulation_deg(0.0, -0.5 * d), 150.0);
1810 }
1811
1812 #[test]
1813 fn new_rejects_bad_inputs() {
1814 assert!(matches!(
1815 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
1816 Err(GeoidError::InvalidDimensions { .. })
1817 ));
1818 assert!(matches!(
1819 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
1820 Err(GeoidError::InvalidSpacing { field: "dlat" })
1821 ));
1822 assert!(matches!(
1823 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
1824 Err(GeoidError::NonFiniteValue { index: 1 })
1825 ));
1826 }
1827
1828 #[test]
1829 fn longitude_normalization_folds_into_half_open_interval() {
1830 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1831 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1832 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1833 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1834 }
1835
1836 #[test]
1841 fn egm96_grid_reproduces_genuine_nodes() {
1842 let nodes: [(f64, f64, f64); 5] = [
1844 (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), ];
1850 for (lat, lon, want) in nodes {
1851 let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1852 assert!(
1853 (got - want).abs() <= 1.0e-9,
1854 "egm96 node ({lat},{lon}): got {got}, want {want}"
1855 );
1856 }
1857 }
1858
1859 #[test]
1870 fn egm96_grid_matches_published_checkpoint() {
1871 let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1872 let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1873 let published = 28.7068;
1874
1875 let egm96 = egm96_undulation(lat, lon);
1876 assert!(
1877 (egm96 - published).abs() < 0.5,
1878 "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1879 );
1880
1881 let coarse = geoid_undulation(lat, lon);
1884 assert!(
1885 (egm96 - published).abs() < (coarse - published).abs(),
1886 "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1887 );
1888 }
1889
1890 #[test]
1891 fn egm96_embedded_outputs_are_bit_pinned() {
1892 let fixtures = [
1893 (37.0_f64, -122.0_f64, 0xc040_accc_cccc_cccdu64),
1894 (37.5_f64, -122.5_f64, 0xc040_de66_6666_6666u64),
1895 (
1896 16.0 + 46.0 / 60.0 + 33.0 / 3600.0,
1897 -(3.0 + 34.0 / 3600.0),
1898 0x403c_acb4_79a8_1af4u64,
1899 ),
1900 (0.125_f64, -179.875_f64, 0x4034_cbf5_c28f_5c29u64),
1901 ];
1902 for (lat_deg, lon_deg, bits) in fixtures {
1903 let got = egm96_undulation(lat_deg.to_radians(), lon_deg.to_radians());
1904 assert_eq!(
1905 got.to_bits(),
1906 bits,
1907 "EGM96 bit pin ({lat_deg}, {lon_deg}) got {got}"
1908 );
1909 }
1910 }
1911
1912 #[test]
1917 fn from_egm96_dac_decodes_the_nga_layout() {
1918 let n_lat = super::EGM96_DAC_N_LAT;
1919 let n_lon = super::EGM96_DAC_N_LON;
1920 let cm = |record: usize, col: usize| -> i16 {
1922 ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1923 };
1924
1925 let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1926 for record in 0..n_lat {
1927 for col in 0..n_lon {
1928 bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1929 }
1930 }
1931 let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1932
1933 let mut values_m = vec![0.0f64; n_lat * n_lon];
1936 for i in 0..n_lat {
1937 let record = n_lat - 1 - i;
1938 for col in 0..n_lon {
1939 values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1940 }
1941 }
1942 let expected =
1943 GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1944 assert_eq!(parsed, expected);
1945
1946 assert!(matches!(
1948 GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1949 Err(GeoidError::Parse { .. })
1950 ));
1951 }
1952
1953 #[test]
1954 fn from_proj_egm96_gtx_rejects_wrong_layout_and_nonfinite_samples() {
1955 let (mut bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
1956 assert!(matches!(
1957 GeoidGrid::from_proj_egm96_gtx(&bytes[..bytes.len() - 4]),
1958 Err(GeoidError::Parse { .. })
1959 ));
1960
1961 bytes[16..24].copy_from_slice(&0.5f64.to_be_bytes());
1962 assert!(matches!(
1963 GeoidGrid::from_proj_egm96_gtx(&bytes),
1964 Err(GeoidError::Parse { .. })
1965 ));
1966
1967 bytes[16..24].copy_from_slice(&0.25f64.to_be_bytes());
1968 bytes[super::PROJ_EGM96_GTX_HEADER_BYTES..super::PROJ_EGM96_GTX_HEADER_BYTES + 4]
1969 .copy_from_slice(&f32::NAN.to_be_bytes());
1970 assert_eq!(
1971 GeoidGrid::from_proj_egm96_gtx(&bytes),
1972 Err(GeoidError::NonFiniteValue { index: 0 })
1973 );
1974 }
1975
1976 #[test]
1977 fn proj_930_egm96_fused_dense_sample_is_zero_ulp() {
1978 let (bytes, point_offset, point_count) = sparse_proj_egm96_gtx_from_dense_fixture();
1979 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
1980
1981 for (index, record) in PROJ_EGM96_930_DENSE_BYTES[point_offset..]
1982 .chunks_exact(24)
1983 .enumerate()
1984 {
1985 let lon_rad = f64::from_bits(u64::from_be_bytes(
1986 record[..8].try_into().expect("fixture longitude"),
1987 ));
1988 let lat_rad = f64::from_bits(u64::from_be_bytes(
1989 record[8..16].try_into().expect("fixture latitude"),
1990 ));
1991 let expected_bits =
1992 u64::from_be_bytes(record[16..24].try_into().expect("fixture undulation"));
1993 let got = grid
1994 .undulation_proj_rad(lat_rad, lon_rad, ProjVgridshiftArithmetic::FusedMultiplyAdd)
1995 .expect("fixture coordinate is inside the grid");
1996 assert_eq!(
1997 got.to_bits(),
1998 expected_bits,
1999 "contracted PROJ 9.3.0 EGM96 point {index}/{point_count}: lat={lat_rad}, lon={lon_rad}, got={got}"
2000 );
2001 }
2002 assert_eq!(point_count, 13_051);
2003 }
2004
2005 #[test]
2006 fn proj_930_egm96_separate_multiply_add_bits_are_pinned() {
2007 let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2008 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2009
2010 let cases = [
2014 (
2015 0xbff9_21e9_072f_0bff,
2016 0x3fb4_1b28_2494_7d44,
2017 0xc03d_88b2_3abe_f2f0,
2018 ),
2019 (
2020 0xbfd9_21e9_072f_0bfc,
2021 0x4006_eef9_c9b9_5ed9,
2022 0x4049_ff99_e8a7_6f47,
2023 ),
2024 (
2025 0x3fd9_21e9_072f_0c01,
2026 0x4004_6b94_c526_cf33,
2027 0x403d_018d_8044_d991,
2028 ),
2029 (
2030 0x3fef_6a63_48fa_cf01,
2031 0x3ffb_a557_324c_2c33,
2032 0xc044_4b8e_0e1f_a00c,
2033 ),
2034 (
2035 0x3ff9_21e9_072f_0bff,
2036 0x4009_21f2_2db9_9c8b,
2037 0x402b_362a_4459_31d0,
2038 ),
2039 ];
2040 for (lat_bits, lon_bits, expected_bits) in cases {
2041 let got = grid
2042 .undulation_proj_rad(
2043 f64::from_bits(lat_bits),
2044 f64::from_bits(lon_bits),
2045 ProjVgridshiftArithmetic::SeparateMultiplyAdd,
2046 )
2047 .expect("fixture coordinate is inside the grid");
2048 assert_eq!(got.to_bits(), expected_bits);
2049 }
2050 }
2051
2052 #[test]
2053 fn proj_lookup_rejects_invalid_coordinates_without_panicking() {
2054 let (bytes, _, _) = sparse_proj_egm96_gtx_from_dense_fixture();
2055 let grid = GeoidGrid::from_proj_egm96_gtx(&bytes).expect("parse sparse public PROJ GTX");
2056 let arithmetic = ProjVgridshiftArithmetic::FusedMultiplyAdd;
2057
2058 assert_eq!(
2059 grid.undulation_proj_rad(f64::NAN, 0.0, arithmetic),
2060 Err(ProjVgridshiftError::NonFiniteCoordinate { field: "latitude" })
2061 );
2062 assert_eq!(
2063 grid.undulation_proj_rad(0.0, f64::INFINITY, arithmetic),
2064 Err(ProjVgridshiftError::NonFiniteCoordinate { field: "longitude" })
2065 );
2066 assert_eq!(
2067 grid.undulation_proj_rad(-91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2068 Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2069 );
2070 assert_eq!(
2071 grid.undulation_proj_rad(91.0 * super::PROJ_DEG_TO_RAD, 0.0, arithmetic),
2072 Err(ProjVgridshiftError::CoordinateOutsideGrid { field: "latitude" })
2073 );
2074 assert!(grid
2075 .undulation_proj_rad(0.0, f64::MAX, arithmetic)
2076 .expect("full-world grids wrap every finite longitude")
2077 .is_finite());
2078 }
2079
2080 #[test]
2081 fn egm96_dac_sparse_fixture_matches_proj_oracle() {
2082 let bytes = sparse_egm96_dac_bytes();
2083 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2084 for fixture in PROJ_EGM96_FIXTURES {
2085 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2086 assert!(
2087 (got - fixture.undulation_m).abs() <= 0.005,
2088 "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
2089 fixture.lat_deg,
2090 fixture.lon_deg,
2091 fixture.undulation_m
2092 );
2093 }
2094 }
2095
2096 #[test]
2097 fn geoid_grid_height_conversions_pin_sign_convention() {
2098 let bytes = sparse_egm96_dac_bytes();
2099 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
2100 for fixture in PROJ_EGM96_FIXTURES {
2101 let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
2102 let h = 250.0;
2103 let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
2104 assert_eq!(orthometric, h - n);
2105 assert_eq!(
2106 grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
2107 orthometric + n
2108 );
2109
2110 let lat_rad = fixture.lat_deg.to_radians();
2111 let lon_rad = fixture.lon_deg.to_radians();
2112 assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
2113 assert!(
2114 (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
2115 .abs()
2116 <= 1.0e-12
2117 );
2118 }
2119 }
2120}