1use std::sync::OnceLock;
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum GeoidError {
66 InvalidDimensions {
68 expected: usize,
70 found: usize,
72 },
73 InvalidSpacing {
75 field: &'static str,
77 },
78 NonFiniteValue {
80 index: usize,
82 },
83 Parse {
85 reason: String,
87 },
88}
89
90impl core::fmt::Display for GeoidError {
91 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
92 match self {
93 Self::InvalidDimensions { expected, found } => {
94 write!(
95 f,
96 "geoid grid expected {expected} samples but found {found}"
97 )
98 }
99 Self::InvalidSpacing { field } => {
100 write!(f, "geoid grid {field} must be finite and positive")
101 }
102 Self::NonFiniteValue { index } => {
103 write!(f, "geoid grid sample {index} is not finite")
104 }
105 Self::Parse { reason } => write!(f, "geoid grid parse error: {reason}"),
106 }
107 }
108}
109
110impl std::error::Error for GeoidError {}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum Egm2008GridSpacing {
119 OneMinute,
121 TwoPointFiveMinute,
123}
124
125impl Egm2008GridSpacing {
126 pub fn arc_minutes(self) -> f64 {
128 match self {
129 Self::OneMinute => 1.0,
130 Self::TwoPointFiveMinute => 2.5,
131 }
132 }
133
134 pub fn degrees(self) -> f64 {
136 self.arc_minutes() / 60.0
137 }
138
139 pub fn global_dimensions(self) -> (usize, usize) {
141 match self {
142 Self::OneMinute => (EGM2008_1_MIN_N_LAT, EGM2008_1_MIN_N_LON),
143 Self::TwoPointFiveMinute => (EGM2008_2P5_MIN_N_LAT, EGM2008_2P5_MIN_N_LON),
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq)]
157pub struct Egm2008RasterWindow {
158 spacing: Egm2008GridSpacing,
159 lat_min_deg: f64,
160 lon_min_deg: f64,
161 n_lat: usize,
162 n_lon: usize,
163}
164
165impl Egm2008RasterWindow {
166 pub fn new(
174 spacing: Egm2008GridSpacing,
175 lat_min_deg: f64,
176 lon_min_deg: f64,
177 n_lat: usize,
178 n_lon: usize,
179 ) -> Result<Self, GeoidError> {
180 if n_lat == 0 || n_lon == 0 {
181 return Err(GeoidError::InvalidDimensions {
182 expected: 1,
183 found: 0,
184 });
185 }
186 if !lat_min_deg.is_finite() {
187 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
188 }
189 if !lon_min_deg.is_finite() {
190 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
191 }
192 let d = spacing.degrees();
193 let lat_max_deg = lat_min_deg + (n_lat as f64 - 1.0) * d;
194 if lat_min_deg < -90.0 - 1.0e-12 || lat_max_deg > 90.0 + 1.0e-12 {
195 return Err(GeoidError::Parse {
196 reason: format!(
197 "EGM2008 latitude window [{lat_min_deg}, {lat_max_deg}] exceeds [-90, 90]"
198 ),
199 });
200 }
201 let lon_span_deg = n_lon as f64 * d;
202 if lon_span_deg > 360.0 + 1.0e-12 {
203 return Err(GeoidError::Parse {
204 reason: format!("EGM2008 longitude span {lon_span_deg} exceeds 360 degrees"),
205 });
206 }
207 Ok(Self {
208 spacing,
209 lat_min_deg,
210 lon_min_deg,
211 n_lat,
212 n_lon,
213 })
214 }
215
216 pub fn global(spacing: Egm2008GridSpacing) -> Self {
218 let (n_lat, n_lon) = spacing.global_dimensions();
219 Self::new(spacing, -90.0, 0.0, n_lat, n_lon)
220 .expect("EGM2008 global raster dimensions are valid")
221 }
222
223 pub fn spacing(self) -> Egm2008GridSpacing {
225 self.spacing
226 }
227
228 pub fn lat_min_deg(self) -> f64 {
230 self.lat_min_deg
231 }
232
233 pub fn lon_min_deg(self) -> f64 {
235 self.lon_min_deg
236 }
237
238 pub fn n_lat(self) -> usize {
240 self.n_lat
241 }
242
243 pub fn n_lon(self) -> usize {
245 self.n_lon
246 }
247}
248
249#[derive(Debug, Clone, PartialEq)]
261pub struct GeoidGrid {
262 lat_min_deg: f64,
263 lon_min_deg: f64,
264 dlat_deg: f64,
265 dlon_deg: f64,
266 n_lat: usize,
267 n_lon: usize,
268 values_m: Vec<f64>,
269}
270
271impl GeoidGrid {
272 pub fn new(
279 lat_min_deg: f64,
280 lon_min_deg: f64,
281 dlat_deg: f64,
282 dlon_deg: f64,
283 n_lat: usize,
284 n_lon: usize,
285 values_m: Vec<f64>,
286 ) -> Result<Self, GeoidError> {
287 if n_lat == 0 || n_lon == 0 {
288 return Err(GeoidError::InvalidDimensions {
289 expected: 1,
290 found: 0,
291 });
292 }
293 let expected = n_lat * n_lon;
294 if values_m.len() != expected {
295 return Err(GeoidError::InvalidDimensions {
296 expected,
297 found: values_m.len(),
298 });
299 }
300 if !lat_min_deg.is_finite() {
301 return Err(GeoidError::InvalidSpacing { field: "lat_min" });
302 }
303 if !lon_min_deg.is_finite() {
304 return Err(GeoidError::InvalidSpacing { field: "lon_min" });
305 }
306 if !dlat_deg.is_finite() || dlat_deg <= 0.0 {
307 return Err(GeoidError::InvalidSpacing { field: "dlat" });
308 }
309 if !dlon_deg.is_finite() || dlon_deg <= 0.0 {
310 return Err(GeoidError::InvalidSpacing { field: "dlon" });
311 }
312 for (index, value) in values_m.iter().enumerate() {
313 if !value.is_finite() {
314 return Err(GeoidError::NonFiniteValue { index });
315 }
316 }
317 Ok(Self {
318 lat_min_deg,
319 lon_min_deg,
320 dlat_deg,
321 dlon_deg,
322 n_lat,
323 n_lon,
324 values_m,
325 })
326 }
327
328 pub fn from_text(text: &str) -> Result<Self, GeoidError> {
346 let mut tokens = text
347 .lines()
348 .map(|line| line.split('#').next().unwrap_or(""))
349 .flat_map(str::split_whitespace);
350
351 let mut next_field = |field: &'static str| -> Result<f64, GeoidError> {
352 let token = tokens.next().ok_or_else(|| GeoidError::Parse {
353 reason: format!("missing header field {field}"),
354 })?;
355 token.parse::<f64>().map_err(|_| GeoidError::Parse {
356 reason: format!("header field {field} is not a number: {token:?}"),
357 })
358 };
359
360 let lat_min_deg = next_field("lat_min")?;
361 let lon_min_deg = next_field("lon_min")?;
362 let dlat_deg = next_field("dlat")?;
363 let dlon_deg = next_field("dlon")?;
364 let n_lat = parse_count(next_field("n_lat")?, "n_lat")?;
365 let n_lon = parse_count(next_field("n_lon")?, "n_lon")?;
366
367 let expected = n_lat.checked_mul(n_lon).ok_or_else(|| GeoidError::Parse {
368 reason: "n_lat * n_lon overflows".to_string(),
369 })?;
370 let mut values_m = Vec::with_capacity(expected);
371 for token in tokens {
372 let value = token.parse::<f64>().map_err(|_| GeoidError::Parse {
373 reason: format!("sample is not a number: {token:?}"),
374 })?;
375 values_m.push(value);
376 }
377
378 Self::new(
379 lat_min_deg,
380 lon_min_deg,
381 dlat_deg,
382 dlon_deg,
383 n_lat,
384 n_lon,
385 values_m,
386 )
387 }
388
389 pub fn from_egm96_dac(bytes: &[u8]) -> Result<Self, GeoidError> {
414 let expected = EGM96_DAC_N_LAT * EGM96_DAC_N_LON * 2;
415 if bytes.len() != expected {
416 return Err(GeoidError::Parse {
417 reason: format!(
418 "EGM96 WW15MGH.DAC must be {expected} bytes ({EGM96_DAC_N_LAT} x {EGM96_DAC_N_LON} big-endian int16), got {}",
419 bytes.len()
420 ),
421 });
422 }
423 let mut values_m = vec![0.0f64; EGM96_DAC_N_LAT * EGM96_DAC_N_LON];
424 for i in 0..EGM96_DAC_N_LAT {
425 let src_row = EGM96_DAC_N_LAT - 1 - i;
428 for c in 0..EGM96_DAC_N_LON {
429 let off = (src_row * EGM96_DAC_N_LON + c) * 2;
430 let cm = i16::from_be_bytes([bytes[off], bytes[off + 1]]);
431 values_m[i * EGM96_DAC_N_LON + c] = f64::from(cm) / 100.0;
432 }
433 }
434 Self::new(
435 -90.0,
436 0.0,
437 0.25,
438 0.25,
439 EGM96_DAC_N_LAT,
440 EGM96_DAC_N_LON,
441 values_m,
442 )
443 }
444
445 pub fn from_egm2008_raster(
457 bytes: &[u8],
458 spacing: Egm2008GridSpacing,
459 ) -> Result<Self, GeoidError> {
460 Self::from_egm2008_raster_window(bytes, Egm2008RasterWindow::global(spacing))
461 }
462
463 pub fn from_egm2008_raster_window(
471 bytes: &[u8],
472 window: Egm2008RasterWindow,
473 ) -> Result<Self, GeoidError> {
474 let values_m = parse_egm2008_raster_values(bytes, window)?;
475 Self::new(
476 window.lat_min_deg,
477 window.lon_min_deg,
478 window.spacing.degrees(),
479 window.spacing.degrees(),
480 window.n_lat,
481 window.n_lon,
482 values_m,
483 )
484 }
485
486 fn is_global_longitude(&self) -> bool {
489 ((self.n_lon as f64 - 1.0) * self.dlon_deg - 360.0).abs() <= 1.0e-6
490 || (self.n_lon as f64 * self.dlon_deg - 360.0).abs() <= 1.0e-6
491 }
492
493 pub fn undulation_rad(&self, lat_rad: f64, lon_rad: f64) -> f64 {
496 self.undulation_deg(lat_rad.to_degrees(), lon_rad.to_degrees())
497 }
498
499 pub fn undulations_rad(&self, points_rad: &[(f64, f64)]) -> Vec<f64> {
505 points_rad
506 .iter()
507 .map(|&(lat_rad, lon_rad)| self.undulation_rad(lat_rad, lon_rad))
508 .collect()
509 }
510
511 pub fn undulation_deg(&self, lat_deg: f64, lon_deg: f64) -> f64 {
514 let lat = lat_deg.clamp(self.lat_min_deg, self.lat_max_deg());
515 let (i0, i1, ty) = self.lat_bracket(lat);
516
517 let (j0, j1, tx) = self.lon_bracket(lon_deg);
518
519 let v00 = self.sample(i0, j0);
520 let v01 = self.sample(i0, j1);
521 let v10 = self.sample(i1, j0);
522 let v11 = self.sample(i1, j1);
523
524 let bottom = v00 + (v01 - v00) * tx;
525 let top = v10 + (v11 - v10) * tx;
526 bottom + (top - bottom) * ty
527 }
528
529 pub fn undulations_deg(&self, points_deg: &[(f64, f64)]) -> Vec<f64> {
535 points_deg
536 .iter()
537 .map(|&(lat_deg, lon_deg)| self.undulation_deg(lat_deg, lon_deg))
538 .collect()
539 }
540
541 pub fn orthometric_height_rad(
545 &self,
546 ellipsoidal_height_m: f64,
547 lat_rad: f64,
548 lon_rad: f64,
549 ) -> f64 {
550 ellipsoidal_height_m - self.undulation_rad(lat_rad, lon_rad)
551 }
552
553 pub fn ellipsoidal_height_rad(
557 &self,
558 orthometric_height_m: f64,
559 lat_rad: f64,
560 lon_rad: f64,
561 ) -> f64 {
562 orthometric_height_m + self.undulation_rad(lat_rad, lon_rad)
563 }
564
565 pub fn orthometric_height_deg(
569 &self,
570 ellipsoidal_height_m: f64,
571 lat_deg: f64,
572 lon_deg: f64,
573 ) -> f64 {
574 ellipsoidal_height_m - self.undulation_deg(lat_deg, lon_deg)
575 }
576
577 pub fn ellipsoidal_height_deg(
581 &self,
582 orthometric_height_m: f64,
583 lat_deg: f64,
584 lon_deg: f64,
585 ) -> f64 {
586 orthometric_height_m + self.undulation_deg(lat_deg, lon_deg)
587 }
588
589 fn lat_max_deg(&self) -> f64 {
590 self.lat_min_deg + (self.n_lat as f64 - 1.0) * self.dlat_deg
591 }
592
593 fn lat_bracket(&self, lat_deg: f64) -> (usize, usize, f64) {
595 if self.n_lat == 1 {
596 return (0, 0, 0.0);
597 }
598 let pos = (lat_deg - self.lat_min_deg) / self.dlat_deg;
599 let pos = pos.clamp(0.0, self.n_lat as f64 - 1.0);
600 let i0 = (pos.floor() as usize).min(self.n_lat - 2);
601 (i0, i0 + 1, pos - i0 as f64)
602 }
603
604 fn lon_bracket(&self, lon_deg: f64) -> (usize, usize, f64) {
607 if self.n_lon == 1 {
608 return (0, 0, 0.0);
609 }
610 let lon = normalize_longitude_deg(lon_deg);
611 if self.is_global_longitude() {
612 let span = self.n_lon as f64 * self.dlon_deg;
613 let mut offset = (lon - self.lon_min_deg).rem_euclid(span);
614 if offset >= span {
616 offset -= span;
617 }
618 let pos = offset / self.dlon_deg;
619 let j0 = (pos.floor() as usize) % self.n_lon;
620 let j1 = (j0 + 1) % self.n_lon;
621 (j0, j1, pos - pos.floor())
622 } else {
623 let pos =
624 ((lon - self.lon_min_deg) / self.dlon_deg).clamp(0.0, self.n_lon as f64 - 1.0);
625 let j0 = (pos.floor() as usize).min(self.n_lon - 2);
626 (j0, j0 + 1, pos - j0 as f64)
627 }
628 }
629
630 fn sample(&self, i: usize, j: usize) -> f64 {
631 self.values_m[i * self.n_lon + j]
632 }
633}
634
635fn parse_count(value: f64, field: &'static str) -> Result<usize, GeoidError> {
637 if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
638 return Err(GeoidError::Parse {
639 reason: format!("{field} must be a positive integer, got {value}"),
640 });
641 }
642 Ok(value as usize)
643}
644
645fn normalize_longitude_deg(lon_deg: f64) -> f64 {
647 let wrapped = (lon_deg + 180.0).rem_euclid(360.0) - 180.0;
648 if wrapped >= 180.0 {
650 wrapped - 360.0
651 } else {
652 wrapped
653 }
654}
655
656pub fn geoid_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
664 builtin_grid().undulation_rad(lat_rad, lon_rad)
665}
666
667pub fn geoid_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
673 builtin_grid().undulations_rad(points_rad)
674}
675
676pub fn geoid_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
681 builtin_grid().undulations_deg(points_deg)
682}
683
684pub fn orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
690 ellipsoidal_height_m - geoid_undulation(lat_rad, lon_rad)
691}
692
693pub fn ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
699 orthometric_height_m + geoid_undulation(lat_rad, lon_rad)
700}
701
702pub fn egm96_orthometric_height_m(ellipsoidal_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
710 ellipsoidal_height_m - egm96_undulation(lat_rad, lon_rad)
711}
712
713pub fn egm96_ellipsoidal_height_m(orthometric_height_m: f64, lat_rad: f64, lon_rad: f64) -> f64 {
721 orthometric_height_m + egm96_undulation(lat_rad, lon_rad)
722}
723
724const EGM96_DAC_N_LAT: usize = 721;
726const EGM96_DAC_N_LON: usize = 1440;
728
729const EGM2008_1_MIN_N_LAT: usize = 10801;
731const EGM2008_1_MIN_N_LON: usize = 21600;
733const EGM2008_2P5_MIN_N_LAT: usize = 4321;
735const EGM2008_2P5_MIN_N_LON: usize = 8640;
737
738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
739enum RasterEndian {
740 Little,
741 Big,
742}
743
744impl RasterEndian {
745 fn read_u32(self, bytes: [u8; 4]) -> u32 {
746 match self {
747 Self::Little => u32::from_le_bytes(bytes),
748 Self::Big => u32::from_be_bytes(bytes),
749 }
750 }
751
752 fn read_f32(self, bytes: [u8; 4]) -> f32 {
753 match self {
754 Self::Little => f32::from_le_bytes(bytes),
755 Self::Big => f32::from_be_bytes(bytes),
756 }
757 }
758}
759
760fn parse_egm2008_raster_values(
761 bytes: &[u8],
762 window: Egm2008RasterWindow,
763) -> Result<Vec<f64>, GeoidError> {
764 let row_value_bytes = window
765 .n_lon
766 .checked_mul(4)
767 .ok_or_else(|| GeoidError::Parse {
768 reason: "EGM2008 row byte count overflows".to_string(),
769 })?;
770 let row_record_bytes = row_value_bytes
771 .checked_add(8)
772 .ok_or_else(|| GeoidError::Parse {
773 reason: "EGM2008 record byte count overflows".to_string(),
774 })?;
775 let expected = window
776 .n_lat
777 .checked_mul(row_record_bytes)
778 .ok_or_else(|| GeoidError::Parse {
779 reason: "EGM2008 raster byte count overflows".to_string(),
780 })?;
781 if bytes.len() != expected {
782 return Err(GeoidError::Parse {
783 reason: format!(
784 "EGM2008 raster window must be {expected} bytes ({} x {} REAL*4 row records), got {}",
785 window.n_lat,
786 window.n_lon,
787 bytes.len()
788 ),
789 });
790 }
791
792 let row_marker = u32::try_from(row_value_bytes).map_err(|_| GeoidError::Parse {
793 reason: "EGM2008 row marker exceeds u32".to_string(),
794 })?;
795 let endian = detect_egm2008_endian(bytes, row_marker)?;
796 let mut values_m = vec![0.0f64; window.n_lat * window.n_lon];
797 for src_row in 0..window.n_lat {
798 let row_off = src_row * row_record_bytes;
799 let start_marker = endian.read_u32([
800 bytes[row_off],
801 bytes[row_off + 1],
802 bytes[row_off + 2],
803 bytes[row_off + 3],
804 ]);
805 let end_off = row_off + 4 + row_value_bytes;
806 let end_marker = endian.read_u32([
807 bytes[end_off],
808 bytes[end_off + 1],
809 bytes[end_off + 2],
810 bytes[end_off + 3],
811 ]);
812 if start_marker != row_marker || end_marker != row_marker {
813 return Err(GeoidError::Parse {
814 reason: format!(
815 "EGM2008 record {src_row} marker mismatch: start {start_marker}, end {end_marker}, expected {row_marker}"
816 ),
817 });
818 }
819 let dst_row = window.n_lat - 1 - src_row;
820 for col in 0..window.n_lon {
821 let sample_off = row_off + 4 + col * 4;
822 let value = endian.read_f32([
823 bytes[sample_off],
824 bytes[sample_off + 1],
825 bytes[sample_off + 2],
826 bytes[sample_off + 3],
827 ]);
828 let index = dst_row * window.n_lon + col;
829 if !value.is_finite() {
830 return Err(GeoidError::NonFiniteValue { index });
831 }
832 values_m[index] = f64::from(value);
833 }
834 }
835 Ok(values_m)
836}
837
838fn detect_egm2008_endian(bytes: &[u8], row_marker: u32) -> Result<RasterEndian, GeoidError> {
839 if bytes.len() < 4 {
840 return Err(GeoidError::Parse {
841 reason: "EGM2008 raster is too short for a record marker".to_string(),
842 });
843 }
844 let first = [bytes[0], bytes[1], bytes[2], bytes[3]];
845 let little = u32::from_le_bytes(first) == row_marker;
846 let big = u32::from_be_bytes(first) == row_marker;
847 match (little, big) {
848 (true, false) => Ok(RasterEndian::Little),
849 (false, true) => Ok(RasterEndian::Big),
850 (true, true) => Err(GeoidError::Parse {
851 reason: "EGM2008 record marker has ambiguous byte order".to_string(),
852 }),
853 (false, false) => Err(GeoidError::Parse {
854 reason: format!("EGM2008 first record marker does not match {row_marker}"),
855 }),
856 }
857}
858
859const EGM96_1DEG_N_LAT: usize = 181;
861const EGM96_1DEG_N_LON: usize = 360;
863
864const EGM96_1DEG_BYTES: &[u8] = include_bytes!("egm96_geoid_1deg.bin");
888
889pub fn egm96_grid() -> &'static GeoidGrid {
894 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
895 GRID.get_or_init(|| {
896 assert_eq!(
897 EGM96_1DEG_BYTES.len(),
898 EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON * 2,
899 "embedded EGM96 1-degree grid has the wrong byte length"
900 );
901 let mut values_m = vec![0.0f64; EGM96_1DEG_N_LAT * EGM96_1DEG_N_LON];
902 for (k, value) in values_m.iter_mut().enumerate() {
903 let cm = i16::from_be_bytes([EGM96_1DEG_BYTES[k * 2], EGM96_1DEG_BYTES[k * 2 + 1]]);
904 *value = f64::from(cm) / 100.0;
905 }
906 GeoidGrid::new(
907 -90.0,
908 0.0,
909 1.0,
910 1.0,
911 EGM96_1DEG_N_LAT,
912 EGM96_1DEG_N_LON,
913 values_m,
914 )
915 .expect("embedded EGM96 1-degree grid is well-formed")
916 })
917}
918
919pub fn egm96_undulation(lat_rad: f64, lon_rad: f64) -> f64 {
929 egm96_grid().undulation_rad(lat_rad, lon_rad)
930}
931
932pub fn egm96_undulations_rad(points_rad: &[(f64, f64)]) -> Vec<f64> {
939 egm96_grid().undulations_rad(points_rad)
940}
941
942pub fn egm96_undulations_deg(points_deg: &[(f64, f64)]) -> Vec<f64> {
948 egm96_grid().undulations_deg(points_deg)
949}
950
951fn builtin_grid() -> &'static GeoidGrid {
953 static GRID: OnceLock<GeoidGrid> = OnceLock::new();
954 GRID.get_or_init(|| {
955 GeoidGrid::new(
956 -90.0,
957 -180.0,
958 30.0,
959 30.0,
960 BUILTIN_N_LAT,
961 BUILTIN_N_LON,
962 BUILTIN_VALUES_M.to_vec(),
963 )
964 .expect("built-in geoid grid is well-formed")
965 })
966}
967
968const BUILTIN_N_LAT: usize = 7; const BUILTIN_N_LON: usize = 13; #[rustfmt::skip]
978const BUILTIN_VALUES_M: [f64; BUILTIN_N_LAT * BUILTIN_N_LON] = [
979 -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,
981 -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,
983 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,
985 -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,
987 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,
989 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,
991 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,
993];
994
995#[cfg(test)]
996mod tests {
997 use super::*;
1027
1028 #[derive(Clone, Copy)]
1029 struct ProjGeoidFixture {
1030 lat_deg: f64,
1031 lon_deg: f64,
1032 undulation_m: f64,
1033 }
1034
1035 const EGM2008_NORCAL_CROP_BYTES: &[u8] =
1036 include_bytes!("../tests/fixtures/geoid/egm2008_25_norcal_crop.bin");
1037
1038 const PROJ_EGM96_FIXTURES: &[ProjGeoidFixture] = &[
1052 ProjGeoidFixture {
1053 lat_deg: 0.000000,
1054 lon_deg: 0.000000,
1055 undulation_m: 17.161579132080,
1056 },
1057 ProjGeoidFixture {
1058 lat_deg: 0.000000,
1059 lon_deg: 80.000000,
1060 undulation_m: -102.687904357910,
1061 },
1062 ProjGeoidFixture {
1063 lat_deg: 60.000000,
1064 lon_deg: -30.000000,
1065 undulation_m: 63.799266815186,
1066 },
1067 ProjGeoidFixture {
1068 lat_deg: 45.625000,
1069 lon_deg: 12.375000,
1070 undulation_m: 44.181870460510,
1071 },
1072 ProjGeoidFixture {
1073 lat_deg: 0.125000,
1074 lon_deg: 179.875000,
1075 undulation_m: 21.099070549011,
1076 },
1077 ProjGeoidFixture {
1078 lat_deg: 0.125000,
1079 lon_deg: -179.875000,
1080 undulation_m: 20.864660263062,
1081 },
1082 ProjGeoidFixture {
1083 lat_deg: -10.500000,
1084 lon_deg: 179.990000,
1085 undulation_m: 38.607539978027,
1086 },
1087 ProjGeoidFixture {
1088 lat_deg: -10.500000,
1089 lon_deg: -179.990000,
1090 undulation_m: 38.540365447998,
1091 },
1092 ProjGeoidFixture {
1093 lat_deg: 89.875000,
1094 lon_deg: 45.000000,
1095 undulation_m: 13.639517307281,
1096 },
1097 ProjGeoidFixture {
1098 lat_deg: -89.875000,
1099 lon_deg: 123.625000,
1100 undulation_m: -29.676423549652,
1101 },
1102 ProjGeoidFixture {
1103 lat_deg: 37.774900,
1104 lon_deg: -122.419400,
1105 undulation_m: -32.242452185586,
1106 },
1107 ];
1108
1109 const PROJ_EGM2008_FIXTURES: &[ProjGeoidFixture] = &[
1110 ProjGeoidFixture {
1111 lat_deg: 37.774900,
1112 lon_deg: -122.419400,
1113 undulation_m: -32.163558372373,
1114 },
1115 ProjGeoidFixture {
1116 lat_deg: 37.500000,
1117 lon_deg: -122.750000,
1118 undulation_m: -33.605857849121,
1119 },
1120 ProjGeoidFixture {
1121 lat_deg: 37.875000,
1122 lon_deg: -122.125000,
1123 undulation_m: -31.847370147705,
1124 },
1125 ProjGeoidFixture {
1126 lat_deg: 38.000000,
1127 lon_deg: -122.000000,
1128 undulation_m: -31.767843246460,
1129 },
1130 ProjGeoidFixture {
1131 lat_deg: 37.000000,
1132 lon_deg: -123.000000,
1133 undulation_m: -36.499370574951,
1134 },
1135 ];
1136
1137 const SPARSE_EGM96_DAC_NODES_CM: &[(f64, f64, i16)] = &[
1146 (-90.00, 123.50, -2953),
1147 (-90.00, 123.75, -2953),
1148 (-89.75, 123.50, -2982),
1149 (-89.75, 123.75, -2982),
1150 (-10.50, 179.75, 3919),
1151 (-10.50, 180.00, 3858),
1152 (-10.50, 180.25, 3751),
1153 (-10.25, 179.75, 3733),
1154 (-10.25, 180.00, 3697),
1155 (-10.25, 180.25, 3611),
1156 (0.00, 0.00, 1716),
1157 (0.00, 0.25, 1708),
1158 (0.00, 80.00, -10269),
1159 (0.00, 80.25, -10255),
1160 (0.00, 179.75, 2138),
1161 (0.00, 180.00, 2115),
1162 (0.00, 180.25, 2095),
1163 (0.25, 0.00, 1719),
1164 (0.25, 0.25, 1711),
1165 (0.25, 80.00, -10286),
1166 (0.25, 80.25, -10276),
1167 (0.25, 179.75, 2109),
1168 (0.25, 180.00, 2078),
1169 (0.25, 180.25, 2058),
1170 (37.75, 237.50, -3237),
1171 (37.75, 237.75, -3204),
1172 (38.00, 237.50, -3211),
1173 (38.00, 237.75, -3200),
1174 (45.50, 12.25, 4398),
1175 (45.50, 12.50, 4355),
1176 (45.75, 12.25, 4498),
1177 (45.75, 12.50, 4421),
1178 (60.00, 330.00, 6380),
1179 (60.00, 330.25, 6400),
1180 (60.25, 330.00, 6365),
1181 (60.25, 330.25, 6388),
1182 (89.75, 45.00, 1367),
1183 (89.75, 45.25, 1367),
1184 (90.00, 45.00, 1361),
1185 (90.00, 45.25, 1361),
1186 ];
1187
1188 fn sparse_egm96_dac_bytes() -> Vec<u8> {
1189 let mut bytes = vec![0u8; super::EGM96_DAC_N_LAT * super::EGM96_DAC_N_LON * 2];
1190 for &(lat_deg, lon_east_deg, cm) in SPARSE_EGM96_DAC_NODES_CM {
1191 let record = ((90.0 - lat_deg) / 0.25).round() as usize;
1192 let col = (lon_east_deg.rem_euclid(360.0) / 0.25).round() as usize;
1193 assert!(record < super::EGM96_DAC_N_LAT);
1194 assert!(col < super::EGM96_DAC_N_LON);
1195 let off = (record * super::EGM96_DAC_N_LON + col) * 2;
1196 bytes[off..off + 2].copy_from_slice(&cm.to_be_bytes());
1197 }
1198 bytes
1199 }
1200
1201 fn egm2008_norcal_window() -> Egm2008RasterWindow {
1202 Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 37.0, -123.0, 25, 25)
1203 .expect("EGM2008 crop window")
1204 }
1205
1206 fn egm2008_test_raster_bytes(
1207 window: Egm2008RasterWindow,
1208 little_endian: bool,
1209 value: impl Fn(usize, usize) -> f32,
1210 ) -> Vec<u8> {
1211 let row_value_bytes = window.n_lon() * 4;
1212 let mut bytes = Vec::with_capacity(window.n_lat() * (row_value_bytes + 8));
1213 for src_row in 0..window.n_lat() {
1214 if little_endian {
1215 bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1216 } else {
1217 bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1218 }
1219 for col in 0..window.n_lon() {
1220 let sample = value(src_row, col);
1221 if little_endian {
1222 bytes.extend_from_slice(&sample.to_le_bytes());
1223 } else {
1224 bytes.extend_from_slice(&sample.to_be_bytes());
1225 }
1226 }
1227 if little_endian {
1228 bytes.extend_from_slice(&(row_value_bytes as u32).to_le_bytes());
1229 } else {
1230 bytes.extend_from_slice(&(row_value_bytes as u32).to_be_bytes());
1231 }
1232 }
1233 bytes
1234 }
1235
1236 #[test]
1237 fn builtin_returns_exact_node_values() {
1238 assert_eq!(geoid_undulation(0.0, 0.0), 17.0);
1240 assert_eq!(geoid_undulation(0.0, 90.0_f64.to_radians()), -60.0);
1242 assert_eq!(
1244 geoid_undulation(60.0_f64.to_radians(), (-30.0_f64).to_radians()),
1245 60.0
1246 );
1247 }
1248
1249 #[test]
1250 fn builtin_captures_major_geoid_features_by_sign() {
1251 let indian_ocean = geoid_undulation(0.0, 80.0_f64.to_radians());
1253 assert!(indian_ocean < -20.0, "indian ocean N = {indian_ocean}");
1254 let north_atlantic = geoid_undulation(55.0_f64.to_radians(), (-25.0_f64).to_radians());
1256 assert!(north_atlantic > 20.0, "north atlantic N = {north_atlantic}");
1257 }
1258
1259 #[test]
1260 fn bilinear_midpoint_is_the_corner_average() {
1261 let grid = GeoidGrid::new(0.0, 0.0, 10.0, 10.0, 2, 2, vec![1.0, 3.0, 5.0, 11.0]).unwrap();
1262 let center = grid.undulation_deg(5.0, 5.0);
1264 assert!((center - (1.0 + 3.0 + 5.0 + 11.0) / 4.0).abs() <= 1.0e-12);
1265 assert!((grid.undulation_deg(0.0, 5.0) - 2.0).abs() <= 1.0e-12);
1267 assert!((grid.undulation_deg(5.0, 0.0) - 3.0).abs() <= 1.0e-12);
1268 assert_eq!(grid.undulation_deg(0.0, 0.0), 1.0);
1270 assert_eq!(grid.undulation_deg(10.0, 10.0), 11.0);
1271 }
1272
1273 #[test]
1274 fn global_grid_wraps_across_the_antimeridian() {
1275 let east = geoid_undulation(0.0, 179.999_f64.to_radians());
1279 let west = geoid_undulation(0.0, (-179.999_f64).to_radians());
1280 assert!((east - west).abs() < 0.01, "seam jump: {east} vs {west}");
1281 assert!((east - (-10.0)).abs() < 0.05, "east seam N = {east}");
1283 assert!((west - (-10.0)).abs() < 0.05, "west seam N = {west}");
1284 let plus = geoid_undulation(0.0, 180.0_f64.to_radians());
1286 let minus = geoid_undulation(0.0, (-180.0_f64).to_radians());
1287 assert_eq!(plus, minus);
1288 assert_eq!(plus, -10.0);
1289 }
1290
1291 #[test]
1292 fn orthometric_height_subtracts_undulation() {
1293 let lat = 0.0;
1294 let lon = 0.0;
1295 let n = geoid_undulation(lat, lon);
1296 assert_eq!(n, 17.0);
1297 assert_eq!(orthometric_height_m(117.0, lat, lon), 100.0);
1299 assert_eq!(ellipsoidal_height_m(100.0, lat, lon), 117.0);
1301 }
1302
1303 #[test]
1304 fn egm96_height_converters_use_the_egm96_undulation() {
1305 let lat = 37.0_f64.to_radians();
1309 let lon = (-122.0_f64).to_radians();
1310 let n = egm96_undulation(lat, lon);
1311 let h = 250.0;
1312 let big_h = egm96_orthometric_height_m(h, lat, lon);
1313 assert_eq!(big_h, h - n);
1314 assert_eq!(egm96_ellipsoidal_height_m(big_h, lat, lon), big_h + n);
1315 assert_ne!(
1317 egm96_orthometric_height_m(h, lat, lon),
1318 orthometric_height_m(h, lat, lon)
1319 );
1320 }
1321
1322 #[test]
1323 fn batch_undulation_entries_match_scalar_lookup() {
1324 let points_deg = [(0.0, 0.0), (45.625, 12.375), (0.125, -179.875)];
1325 let got_deg = egm96_undulations_deg(&points_deg);
1326 let expected_deg: Vec<f64> = points_deg
1327 .iter()
1328 .map(|&(lat, lon)| egm96_grid().undulation_deg(lat, lon))
1329 .collect();
1330 assert_eq!(got_deg, expected_deg);
1331
1332 let points_rad: Vec<(f64, f64)> = points_deg
1333 .iter()
1334 .map(|&(lat, lon)| (lat.to_radians(), lon.to_radians()))
1335 .collect();
1336 let got_rad = egm96_undulations_rad(&points_rad);
1337 let expected_rad: Vec<f64> = points_rad
1338 .iter()
1339 .map(|&(lat, lon)| egm96_undulation(lat, lon))
1340 .collect();
1341 assert_eq!(got_rad, expected_rad);
1342
1343 assert_eq!(
1344 geoid_undulations_deg(&points_deg),
1345 points_deg
1346 .iter()
1347 .map(|&(lat, lon)| geoid_undulation(lat.to_radians(), lon.to_radians()))
1348 .collect::<Vec<_>>()
1349 );
1350 }
1351
1352 #[test]
1353 fn from_text_round_trips_a_grid() {
1354 let text = "\
1355# coarse 2x3 regional grid
1356# lat_min lon_min dlat dlon n_lat n_lon
135710.0 20.0 5.0 5.0 2 3
1358 1.0 2.0 3.0 # lat 10 row
1359 4.0 5.0 6.0 # lat 15 row
1360";
1361 let grid = GeoidGrid::from_text(text).expect("parse grid");
1362 assert_eq!(grid.undulation_deg(10.0, 20.0), 1.0);
1363 assert_eq!(grid.undulation_deg(15.0, 30.0), 6.0);
1364 let center = grid.undulation_deg(12.5, 22.5);
1366 assert!((center - (1.0 + 2.0 + 4.0 + 5.0) / 4.0).abs() <= 1.0e-12);
1367 assert_eq!(
1369 grid.undulation_deg(10.0, 0.0),
1370 grid.undulation_deg(10.0, 20.0)
1371 );
1372 }
1373
1374 #[test]
1375 fn from_text_rejects_short_data() {
1376 let text = "0.0 0.0 1.0 1.0 2 2\n1.0 2.0 3.0\n";
1377 assert_eq!(
1378 GeoidGrid::from_text(text),
1379 Err(GeoidError::InvalidDimensions {
1380 expected: 4,
1381 found: 3
1382 })
1383 );
1384 }
1385
1386 #[test]
1387 fn from_egm2008_raster_window_decodes_little_and_big_endian_records() {
1388 let d = Egm2008GridSpacing::TwoPointFiveMinute.degrees();
1389 let window =
1390 Egm2008RasterWindow::new(Egm2008GridSpacing::TwoPointFiveMinute, 10.0, 20.0, 2, 3)
1391 .expect("EGM2008 test window");
1392 for little_endian in [true, false] {
1393 let bytes = egm2008_test_raster_bytes(window, little_endian, |src_row, col| {
1394 (src_row * 10 + col) as f32
1395 });
1396 let grid =
1397 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008");
1398 assert_eq!(grid.undulation_deg(10.0, 20.0), 10.0);
1399 assert!((grid.undulation_deg(10.0 + d, 20.0 + 2.0 * d) - 2.0).abs() <= 1.0e-12);
1400 assert!((grid.undulation_deg(10.0 + 0.5 * d, 20.0 + d) - 6.0).abs() <= 1.0e-12);
1401 }
1402 }
1403
1404 #[test]
1405 fn from_egm2008_raster_rejects_bad_record_layout() {
1406 assert!(matches!(
1407 GeoidGrid::from_egm2008_raster(
1408 EGM2008_NORCAL_CROP_BYTES,
1409 Egm2008GridSpacing::TwoPointFiveMinute,
1410 ),
1411 Err(GeoidError::Parse { .. })
1412 ));
1413
1414 let window = egm2008_norcal_window();
1415 let mut bytes = EGM2008_NORCAL_CROP_BYTES.to_vec();
1416 bytes[0] = 0;
1417 assert!(matches!(
1418 GeoidGrid::from_egm2008_raster_window(&bytes, window),
1419 Err(GeoidError::Parse { .. })
1420 ));
1421 }
1422
1423 #[test]
1424 fn egm2008_crop_matches_proj_oracle() {
1425 let grid = GeoidGrid::from_egm2008_raster_window(
1426 EGM2008_NORCAL_CROP_BYTES,
1427 egm2008_norcal_window(),
1428 )
1429 .expect("parse EGM2008 crop");
1430 for fixture in PROJ_EGM2008_FIXTURES {
1431 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1432 assert!(
1433 (got - fixture.undulation_m).abs() <= 0.005,
1434 "PROJ EGM2008 fixture ({}, {}): got {got}, want {}",
1435 fixture.lat_deg,
1436 fixture.lon_deg,
1437 fixture.undulation_m
1438 );
1439 }
1440 }
1441
1442 #[test]
1443 fn egm2008_regional_crop_clamps_grid_edges() {
1444 let grid = GeoidGrid::from_egm2008_raster_window(
1445 EGM2008_NORCAL_CROP_BYTES,
1446 egm2008_norcal_window(),
1447 )
1448 .expect("parse EGM2008 crop");
1449 assert_eq!(
1450 grid.undulation_deg(36.0, -124.0),
1451 grid.undulation_deg(37.0, -123.0)
1452 );
1453 assert_eq!(
1454 grid.undulation_deg(39.0, -121.0),
1455 grid.undulation_deg(38.0, -122.0)
1456 );
1457 }
1458
1459 #[test]
1460 fn egm2008_global_longitude_window_wraps_through_shared_kernel() {
1461 let spacing = Egm2008GridSpacing::TwoPointFiveMinute;
1462 let d = spacing.degrees();
1463 let (_, n_lon) = spacing.global_dimensions();
1464 let window = Egm2008RasterWindow::new(spacing, 0.0, 0.0, 2, n_lon)
1465 .expect("global-longitude EGM2008 window");
1466 let bytes = egm2008_test_raster_bytes(window, true, |_, col| {
1467 if col == 0 {
1468 100.0
1469 } else if col == n_lon - 1 {
1470 200.0
1471 } else {
1472 10.0
1473 }
1474 });
1475 let grid =
1476 GeoidGrid::from_egm2008_raster_window(&bytes, window).expect("parse EGM2008 wrap");
1477
1478 assert_eq!(grid.undulation_deg(0.0, 360.0), 100.0);
1479 assert_eq!(grid.undulation_deg(0.0, -0.5 * d), 150.0);
1480 }
1481
1482 #[test]
1483 fn new_rejects_bad_inputs() {
1484 assert!(matches!(
1485 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![1.0, 2.0, 3.0]),
1486 Err(GeoidError::InvalidDimensions { .. })
1487 ));
1488 assert!(matches!(
1489 GeoidGrid::new(0.0, 0.0, 0.0, 1.0, 2, 2, vec![0.0; 4]),
1490 Err(GeoidError::InvalidSpacing { field: "dlat" })
1491 ));
1492 assert!(matches!(
1493 GeoidGrid::new(0.0, 0.0, 1.0, 1.0, 2, 2, vec![0.0, f64::NAN, 0.0, 0.0]),
1494 Err(GeoidError::NonFiniteValue { index: 1 })
1495 ));
1496 }
1497
1498 #[test]
1499 fn longitude_normalization_folds_into_half_open_interval() {
1500 assert!((normalize_longitude_deg(190.0) - (-170.0)).abs() <= 1.0e-12);
1501 assert!((normalize_longitude_deg(-190.0) - 170.0).abs() <= 1.0e-12);
1502 assert!((normalize_longitude_deg(180.0) - (-180.0)).abs() <= 1.0e-12);
1503 assert!((normalize_longitude_deg(360.0)).abs() <= 1.0e-12);
1504 }
1505
1506 #[test]
1511 fn egm96_grid_reproduces_genuine_nodes() {
1512 let nodes: [(f64, f64, f64); 5] = [
1514 (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), ];
1520 for (lat, lon, want) in nodes {
1521 let got = egm96_undulation(lat.to_radians(), lon.to_radians());
1522 assert!(
1523 (got - want).abs() <= 1.0e-9,
1524 "egm96 node ({lat},{lon}): got {got}, want {want}"
1525 );
1526 }
1527 }
1528
1529 #[test]
1540 fn egm96_grid_matches_published_checkpoint() {
1541 let lat = (16.0 + 46.0 / 60.0 + 33.0 / 3600.0_f64).to_radians();
1542 let lon = (-(3.0 + 0.0 / 60.0 + 34.0 / 3600.0_f64)).to_radians();
1543 let published = 28.7068;
1544
1545 let egm96 = egm96_undulation(lat, lon);
1546 assert!(
1547 (egm96 - published).abs() < 0.5,
1548 "egm96 Timbuktu {egm96} not within 0.5 m of published {published}"
1549 );
1550
1551 let coarse = geoid_undulation(lat, lon);
1554 assert!(
1555 (egm96 - published).abs() < (coarse - published).abs(),
1556 "egm96 ({egm96}) should beat the coarse built-in ({coarse}) vs {published}"
1557 );
1558 }
1559
1560 #[test]
1561 fn egm96_embedded_outputs_are_bit_pinned() {
1562 let fixtures = [
1563 (37.0_f64, -122.0_f64, 0xc040_accc_cccc_cccdu64),
1564 (37.5_f64, -122.5_f64, 0xc040_de66_6666_6666u64),
1565 (
1566 16.0 + 46.0 / 60.0 + 33.0 / 3600.0,
1567 -(3.0 + 34.0 / 3600.0),
1568 0x403c_acb4_79a8_1af4u64,
1569 ),
1570 (0.125_f64, -179.875_f64, 0x4034_cbf5_c28f_5c29u64),
1571 ];
1572 for (lat_deg, lon_deg, bits) in fixtures {
1573 let got = egm96_undulation(lat_deg.to_radians(), lon_deg.to_radians());
1574 assert_eq!(
1575 got.to_bits(),
1576 bits,
1577 "EGM96 bit pin ({lat_deg}, {lon_deg}) got {got}"
1578 );
1579 }
1580 }
1581
1582 #[test]
1587 fn from_egm96_dac_decodes_the_nga_layout() {
1588 let n_lat = super::EGM96_DAC_N_LAT;
1589 let n_lon = super::EGM96_DAC_N_LON;
1590 let cm = |record: usize, col: usize| -> i16 {
1592 ((record as i32) - 360 + (col as i32 % 11) - 5) as i16
1593 };
1594
1595 let mut bytes = Vec::with_capacity(n_lat * n_lon * 2);
1596 for record in 0..n_lat {
1597 for col in 0..n_lon {
1598 bytes.extend_from_slice(&cm(record, col).to_be_bytes());
1599 }
1600 }
1601 let parsed = GeoidGrid::from_egm96_dac(&bytes).expect("parse synthetic DAC");
1602
1603 let mut values_m = vec![0.0f64; n_lat * n_lon];
1606 for i in 0..n_lat {
1607 let record = n_lat - 1 - i;
1608 for col in 0..n_lon {
1609 values_m[i * n_lon + col] = f64::from(cm(record, col)) / 100.0;
1610 }
1611 }
1612 let expected =
1613 GeoidGrid::new(-90.0, 0.0, 0.25, 0.25, n_lat, n_lon, values_m).expect("reference grid");
1614 assert_eq!(parsed, expected);
1615
1616 assert!(matches!(
1618 GeoidGrid::from_egm96_dac(&bytes[..bytes.len() - 2]),
1619 Err(GeoidError::Parse { .. })
1620 ));
1621 }
1622
1623 #[test]
1624 fn egm96_dac_sparse_fixture_matches_proj_oracle() {
1625 let bytes = sparse_egm96_dac_bytes();
1626 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1627 for fixture in PROJ_EGM96_FIXTURES {
1628 let got = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1629 assert!(
1630 (got - fixture.undulation_m).abs() <= 0.005,
1631 "PROJ EGM96 fixture ({}, {}): got {got}, want {}",
1632 fixture.lat_deg,
1633 fixture.lon_deg,
1634 fixture.undulation_m
1635 );
1636 }
1637 }
1638
1639 #[test]
1640 fn geoid_grid_height_conversions_pin_sign_convention() {
1641 let bytes = sparse_egm96_dac_bytes();
1642 let grid = GeoidGrid::from_egm96_dac(&bytes).expect("parse sparse EGM96 DAC fixture");
1643 for fixture in PROJ_EGM96_FIXTURES {
1644 let n = grid.undulation_deg(fixture.lat_deg, fixture.lon_deg);
1645 let h = 250.0;
1646 let orthometric = grid.orthometric_height_deg(h, fixture.lat_deg, fixture.lon_deg);
1647 assert_eq!(orthometric, h - n);
1648 assert_eq!(
1649 grid.ellipsoidal_height_deg(orthometric, fixture.lat_deg, fixture.lon_deg),
1650 orthometric + n
1651 );
1652
1653 let lat_rad = fixture.lat_deg.to_radians();
1654 let lon_rad = fixture.lon_deg.to_radians();
1655 assert!((grid.orthometric_height_rad(h, lat_rad, lon_rad) - (h - n)).abs() <= 1.0e-12);
1656 assert!(
1657 (grid.ellipsoidal_height_rad(orthometric, lat_rad, lon_rad) - (orthometric + n))
1658 .abs()
1659 <= 1.0e-12
1660 );
1661 }
1662 }
1663}