1#![forbid(unsafe_code)]
2use crate::{TransBuild, TransParams};
7use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
8use oxiproj_grids::{
9 read_geotiff, read_geotiff_gdal_metadata, read_geotiff_hierarchy, read_gtx, read_ntv2,
10 sample_grid, BandPositive, BandRole, BandUnit, GridBand, GridSet,
11};
12use std::f64::consts::PI;
13
14const ARCSEC_TO_RAD: f64 = PI / 648_000.0;
15const DEG_TO_RAD: f64 = PI / 180.0;
16const MAX_ITER: usize = 10;
17const ITER_TOL: f64 = 1e-12;
18const REL_TOLERANCE_HGRIDSHIFT: f64 = 1e-5;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(crate) enum Interpolation {
26 Bilinear,
28 Biquadratic,
31}
32
33pub(crate) fn is_tiff(data: &[u8]) -> bool {
37 data.starts_with(b"II\x2a\x00") || data.starts_with(b"MM\x00\x2a")
38}
39
40pub(crate) fn resolve_grid_list(
52 registry: Option<&dyn crate::GridRegistry>,
53 csv: &str,
54) -> ProjResult<Vec<(String, Vec<u8>)>> {
55 let mut out = Vec::new();
56 for raw in csv.split(',') {
57 let entry = raw.trim();
58 if entry.is_empty() {
59 continue;
60 }
61 let (name, can_fail) = match entry.strip_prefix('@') {
62 Some(rest) => (rest.trim(), true),
63 None => (entry, false),
64 };
65 if name.is_empty() {
66 continue;
67 }
68 match resolve_grid_bytes(registry, name) {
69 Ok(bytes) => out.push((name.to_string(), bytes)),
70 Err(e) => {
71 if !can_fail {
72 return Err(e);
73 }
74 }
75 }
76 }
77 Ok(out)
78}
79
80pub(crate) fn parse_time_gate(p: &TransParams) -> (f64, f64) {
87 let t_epoch = p.params.get_f64("t_epoch").unwrap_or(0.0);
88 let t_final = match p.params.get_f64("t_final") {
89 Some(v) if v != 0.0 => v,
90 _ => {
91 if p.params.get_str("t_final") == Some("now") {
93 decimal_year_now()
94 } else {
95 0.0
96 }
97 }
98 };
99 (t_epoch, t_final)
100}
101
102pub(crate) fn time_gate_applies(t_epoch: f64, t_final: f64, t: f64) -> bool {
107 if t_final == 0.0 || t_epoch == 0.0 {
108 return true;
109 }
110 t < t_epoch && t_final > t_epoch
111}
112
113fn decimal_year_now() -> f64 {
118 let secs = std::time::SystemTime::now()
119 .duration_since(std::time::UNIX_EPOCH)
120 .map(|d| d.as_secs() as i64)
121 .unwrap_or(0);
122 let days = secs.div_euclid(86_400);
123 let (year, month, day) = civil_from_days(days);
124 let doy = day_of_year(year, month, day);
125 year as f64 + doy as f64 / 365.0
126}
127
128fn civil_from_days(days: i64) -> (i64, u32, u32) {
132 let z = days + 719_468;
133 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
134 let doe = z - era * 146_097;
135 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
136 let y = yoe + era * 400;
137 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
138 let mp = (5 * doy + 2) / 153;
139 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
140 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
141 let year = if m <= 2 { y + 1 } else { y };
142 (year, m, d)
143}
144
145fn day_of_year(year: i64, month: u32, day: u32) -> u32 {
147 const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
148 let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
149 let mut doy = CUM[(month.clamp(1, 12) - 1) as usize] + day.saturating_sub(1);
150 if leap && month > 2 {
151 doy += 1;
152 }
153 doy
154}
155
156pub(crate) fn resolve_vertical_band(gs: &GridSet) -> Option<usize> {
161 if gs.bands.is_empty() {
162 return None;
163 }
164 for (i, band) in gs.bands.iter().enumerate() {
165 if matches!(
166 band.semantics.role,
167 BandRole::GeoidUndulation | BandRole::VerticalOffset
168 ) {
169 return Some(i);
170 }
171 }
172 Some(0)
175}
176
177pub(crate) fn vertical_gridset(mut gs: GridSet) -> ProjResult<GridSet> {
182 let idx = resolve_vertical_band(&gs).ok_or(ProjError::FileNotFound)?;
183 if idx != 0 {
184 gs.bands.swap(0, idx);
185 }
186 gs.bands.truncate(1);
187 Ok(gs)
188}
189
190#[derive(Debug, Clone, Copy)]
195pub(crate) struct HShiftPlan {
196 idx_lat: usize,
197 idx_long: usize,
198 conv_factor_to_rad: f64,
199 positive_east: bool,
200}
201
202fn unit_conv_factor(unit: BandUnit) -> Option<f64> {
205 match unit {
206 BandUnit::ArcSecond | BandUnit::Unknown => Some(ARCSEC_TO_RAD),
209 BandUnit::Degree => Some(DEG_TO_RAD),
210 BandUnit::Radian => Some(1.0),
211 BandUnit::Metre => None,
212 }
213}
214
215pub(crate) fn resolve_hshift_plan(gs: &GridSet) -> Option<HShiftPlan> {
224 let nbands = gs.bands.len();
225 if nbands < 2 {
226 return None;
227 }
228
229 let mut idx_lat = 0usize;
231 let mut idx_long = 1usize;
232 let mut found_lat = false;
233 let mut found_long = false;
234 let mut found_any_desc = false;
235
236 for (i, band) in gs.bands.iter().enumerate() {
237 match band.semantics.role {
238 BandRole::LatitudeOffset => {
239 idx_lat = i;
240 found_lat = true;
241 found_any_desc = true;
242 }
243 BandRole::LongitudeOffset => {
244 idx_long = i;
245 found_long = true;
246 found_any_desc = true;
247 }
248 BandRole::Unknown => {}
249 BandRole::GeoidUndulation | BandRole::VerticalOffset => {
252 found_any_desc = true;
253 }
254 }
255 }
256
257 if found_any_desc && !found_lat && !found_long {
259 return None;
260 }
261 if found_lat != found_long {
263 return None;
264 }
265 if idx_lat >= nbands || idx_long >= nbands {
266 return None;
267 }
268
269 let conv_factor_to_rad = unit_conv_factor(gs.bands[idx_lat].semantics.unit)?;
271 let positive_east = gs.bands[idx_long].semantics.positive != BandPositive::West;
273
274 Some(HShiftPlan {
275 idx_lat,
276 idx_long,
277 conv_factor_to_rad,
278 positive_east,
279 })
280}
281
282pub(crate) fn geotiff_horizontal_offset(plan: &HShiftPlan, shifts: &[f64]) -> (f64, f64) {
287 let raw_lat = shifts.get(plan.idx_lat).copied().unwrap_or(0.0);
288 let raw_long = shifts.get(plan.idx_long).copied().unwrap_or(0.0);
289 let dlat = raw_lat * plan.conv_factor_to_rad;
290 let mut dlon = raw_long * plan.conv_factor_to_rad;
291 if !plan.positive_east {
292 dlon = -dlon;
293 }
294 (dlon, dlat)
295}
296
297#[cfg(test)]
303pub(crate) fn build_horizontal_geotiff(gs: GridSet) -> ProjResult<TransBuild> {
304 let plan = resolve_hshift_plan(&gs).ok_or(ProjError::UnsupportedOperation)?;
305 Ok(TransBuild::new(
306 Box::new(GridShift {
307 grids: vec![gs],
308 kind: ShiftKind::HorizontalGeoTiff,
309 hplan: Some(plan),
310 idx_z: None,
311 interpolation: Interpolation::Bilinear,
312 no_z_transform: false,
313 }),
314 IoUnits::Radians,
315 IoUnits::Radians,
316 ))
317}
318
319fn quadratic_interpol(x: f64, f0: f64, f1: f64, f2: f64) -> f64 {
324 let df0 = f1 - f0;
325 let df1 = f2 - f1;
326 let d2f0 = df1 - df0;
327 f0 + x * df0 + 0.5 * x * (x - 1.0) * d2f0
328}
329
330fn sample_grid_biquadratic(gs: &GridSet, lat_deg: f64, lon_deg: f64) -> Option<Vec<f64>> {
341 if gs.bands.is_empty() {
342 return None;
343 }
344 let mut results = Vec::with_capacity(gs.bands.len());
345 for band in &gs.bands {
346 results.push(band_biquadratic(band, lat_deg, lon_deg)?);
347 }
348 Some(results)
349}
350
351fn band_biquadratic(band: &GridBand, lat_deg: f64, lon_deg: f64) -> Option<f64> {
354 let ext = &band.extent;
355 let width = ext.cols() as i64;
356 let height = ext.rows() as i64;
357 if width < 3 || height < 3 {
359 return band_bilinear_fallback(band, lat_deg, lon_deg);
360 }
361
362 let mut lon = lon_deg;
364 let eps = (ext.lon_inc + ext.lat_inc) * REL_TOLERANCE_HGRIDSHIFT;
365 if lon < ext.ll_lon - eps {
366 lon += 360.0;
367 } else if lon > ext.ur_lon + eps {
368 lon -= 360.0;
369 }
370
371 let x = (lon - ext.ll_lon) / ext.lon_inc;
373 let y = (lat_deg - ext.ll_lat) / ext.lat_inc;
374 let mut ix = x.floor() as i64;
375 let mut iy = y.floor() as i64;
376 let mut fx = x - ix as f64;
377 let mut fy = y - iy as f64;
378
379 if ix < 0 {
381 if ix == -1 && fx > 1.0 - 10.0 * REL_TOLERANCE_HGRIDSHIFT {
382 ix += 1;
383 fx = 0.0;
384 } else {
385 return None;
386 }
387 } else if ix + 1 >= width {
388 if ix + 1 == width && fx < 10.0 * REL_TOLERANCE_HGRIDSHIFT {
389 ix -= 1;
390 fx = 1.0;
391 } else {
392 return None;
393 }
394 }
395 if iy < 0 {
396 if iy == -1 && fy > 1.0 - 10.0 * REL_TOLERANCE_HGRIDSHIFT {
397 iy += 1;
398 fy = 0.0;
399 } else {
400 return None;
401 }
402 } else if iy + 1 >= height {
403 if iy + 1 == height && fy < 10.0 * REL_TOLERANCE_HGRIDSHIFT {
404 iy -= 1;
405 fy = 1.0;
406 } else {
407 return None;
408 }
409 }
410
411 if (fx <= 0.5 && ix > 0) || (ix + 2 == width) {
413 ix -= 1;
414 fx += 1.0;
415 }
416 if (fy <= 0.5 && iy > 0) || (iy + 2 == height) {
417 iy -= 1;
418 fy += 1.0;
419 }
420 if ix < 0 || iy < 0 || ix + 2 >= width || iy + 2 >= height {
421 return None;
422 }
423
424 let mut row_vals = [0.0f64; 3];
428 for (j, slot) in row_vals.iter_mut().enumerate() {
429 let north_row = (height - 1 - (iy + j as i64)) as usize;
430 let mut cols = [0.0f64; 3];
431 for (i, c) in cols.iter_mut().enumerate() {
432 let v = band.get(north_row, (ix + i as i64) as usize)? as f64;
433 if !v.is_finite() {
434 return None;
435 }
436 *c = v;
437 }
438 *slot = quadratic_interpol(fx, cols[0], cols[1], cols[2]);
439 }
440 Some(quadratic_interpol(
441 fy,
442 row_vals[0],
443 row_vals[1],
444 row_vals[2],
445 ))
446}
447
448fn band_bilinear_fallback(band: &GridBand, lat_deg: f64, lon_deg: f64) -> Option<f64> {
452 let tmp = GridSet {
453 bands: vec![band.clone()],
454 source: oxiproj_grids::GridSource::Memory,
455 };
456 sample_grid(&tmp, lat_deg, lon_deg).and_then(|v| v.first().copied())
457}
458
459fn sample_with(
462 interp: Interpolation,
463 gs: &GridSet,
464 lat_deg: f64,
465 lon_deg: f64,
466) -> Option<Vec<f64>> {
467 match interp {
468 Interpolation::Bilinear => sample_grid(gs, lat_deg, lon_deg),
469 Interpolation::Biquadratic => sample_grid_biquadratic(gs, lat_deg, lon_deg),
470 }
471}
472
473pub(crate) fn resolve_grid_bytes(
496 registry: Option<&dyn crate::GridRegistry>,
497 name: &str,
498) -> ProjResult<Vec<u8>> {
499 if let Some(reg) = registry {
501 if let Some(bytes) = reg.get_grid(name) {
502 return Ok(bytes.to_vec());
503 }
504 }
505 if let Some(bytes) = read_from_resource_dirs(name) {
507 return Ok(bytes);
508 }
509 #[cfg(feature = "network")]
511 {
512 if let Some(bytes) = fetch_from_cdn(name) {
513 return Ok(bytes);
514 }
515 }
516 Err(ProjError::FileNotFound)
517}
518
519fn read_from_resource_dirs(name: &str) -> Option<Vec<u8>> {
528 for dir in oxiproj_grids::resource_dirs() {
529 let path = dir.join(name);
530 if path.is_file() {
531 if let Ok(bytes) = std::fs::read(&path) {
532 return Some(bytes);
533 }
534 }
535 }
536 None
537}
538
539#[cfg(feature = "network")]
553fn fetch_from_cdn(name: &str) -> Option<Vec<u8>> {
554 if !oxiproj_grids::network_build_allowed() {
555 return None;
556 }
557 let dir = oxiproj_grids::GridDiskCache::default_dir().unwrap_or_else(std::env::temp_dir);
558 let mut resolver = oxiproj_grids::GridResolver::new(dir).ok()?;
559 if resolver.ensure(name).ok()? {
560 return resolver.get_loaded(name).map(<[u8]>::to_vec);
561 }
562 None
563}
564
565#[derive(Debug)]
567enum ShiftKind {
568 HorizontalArcSec,
570 Vertical,
572 HorizontalGeoTiff,
575 Geographic3DOffset,
579 Xyz,
581}
582
583#[derive(Debug)]
584struct GridShift {
585 grids: Vec<GridSet>,
586 kind: ShiftKind,
587 hplan: Option<HShiftPlan>,
590 idx_z: Option<usize>,
593 interpolation: Interpolation,
595 no_z_transform: bool,
597}
598
599impl Operation for GridShift {
600 fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
601 let v = c.v();
602 if self.grids.is_empty() {
606 return Ok(c);
607 }
608 let lon_deg = v[0].to_degrees();
609 let lat_deg = v[1].to_degrees();
610 for gs in &self.grids {
611 if let Some(shifts) = sample_with(self.interpolation, gs, lat_deg, lon_deg) {
612 return match self.kind {
613 ShiftKind::HorizontalArcSec => Ok(Coord::new(
614 v[0] + shifts[1] * ARCSEC_TO_RAD,
615 v[1] + shifts[0] * ARCSEC_TO_RAD,
616 v[2],
617 v[3],
618 )),
619 ShiftKind::Vertical => {
626 let z = if self.no_z_transform {
627 v[2]
628 } else {
629 v[2] + shifts[0]
630 };
631 Ok(Coord::new(v[0], v[1], z, v[3]))
632 }
633 ShiftKind::HorizontalGeoTiff => {
634 let plan = self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
635 let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
636 Ok(Coord::new(v[0] + dlon, v[1] + dlat, v[2], v[3]))
637 }
638 ShiftKind::Geographic3DOffset => {
639 let plan = self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
640 let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
641 let dz = self.geog3d_dz(&shifts);
642 Ok(Coord::new(v[0] + dlon, v[1] + dlat, v[2] + dz, v[3]))
643 }
644 ShiftKind::Xyz => {
645 let dz = if self.no_z_transform || shifts.len() < 3 {
646 0.0
647 } else {
648 shifts[2]
649 };
650 Ok(Coord::new(
651 v[0] + shifts[0].to_radians(),
652 v[1] + shifts[1].to_radians(),
653 v[2] + dz,
654 v[3],
655 ))
656 }
657 };
658 }
659 }
660 Err(ProjError::OutsideGrid)
661 }
662
663 fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
664 let v = c.v();
665 if self.grids.is_empty() {
666 return Ok(c);
667 }
668 match self.kind {
669 ShiftKind::Vertical => {
670 for gs in &self.grids {
675 if let Some(shifts) =
676 sample_with(self.interpolation, gs, v[1].to_degrees(), v[0].to_degrees())
677 {
678 let z = if self.no_z_transform {
679 v[2]
680 } else {
681 v[2] - shifts[0]
682 };
683 return Ok(Coord::new(v[0], v[1], z, v[3]));
684 }
685 }
686 Err(ProjError::OutsideGrid)
687 }
688 _ => {
689 let mut lon_r = v[0];
691 let mut lat_r = v[1];
692 'outer: for gs in &self.grids {
693 if sample_with(
694 self.interpolation,
695 gs,
696 lat_r.to_degrees(),
697 lon_r.to_degrees(),
698 )
699 .is_none()
700 {
701 continue;
702 }
703 for _ in 0..MAX_ITER {
704 let shifts = match sample_with(
705 self.interpolation,
706 gs,
707 lat_r.to_degrees(),
708 lon_r.to_degrees(),
709 ) {
710 Some(s) => s,
711 None => break 'outer,
712 };
713 let (new_lon, new_lat) = match self.kind {
714 ShiftKind::HorizontalArcSec => (
715 v[0] - shifts[1] * ARCSEC_TO_RAD,
716 v[1] - shifts[0] * ARCSEC_TO_RAD,
717 ),
718 ShiftKind::HorizontalGeoTiff | ShiftKind::Geographic3DOffset => {
719 let plan =
720 self.hplan.as_ref().ok_or(ProjError::UnsupportedOperation)?;
721 let (dlon, dlat) = geotiff_horizontal_offset(plan, &shifts);
722 (v[0] - dlon, v[1] - dlat)
723 }
724 ShiftKind::Xyz => {
725 (v[0] - shifts[0].to_radians(), v[1] - shifts[1].to_radians())
726 }
727 ShiftKind::Vertical => (v[0], v[1]),
731 };
732 let dlon = (new_lon - lon_r).abs();
733 let dlat = (new_lat - lat_r).abs();
734 lon_r = new_lon;
735 lat_r = new_lat;
736 if dlon < ITER_TOL && dlat < ITER_TOL {
737 break;
738 }
739 }
740 if let Some(shifts) = sample_with(
742 self.interpolation,
743 gs,
744 lat_r.to_degrees(),
745 lon_r.to_degrees(),
746 ) {
747 let dz = match self.kind {
748 ShiftKind::Xyz => {
749 if self.no_z_transform || shifts.len() < 3 {
750 0.0
751 } else {
752 shifts[2]
753 }
754 }
755 ShiftKind::Geographic3DOffset => self.geog3d_dz(&shifts),
756 _ => 0.0,
757 };
758 return Ok(Coord::new(lon_r, lat_r, v[2] - dz, v[3]));
759 }
760 return Ok(Coord::new(lon_r, lat_r, v[2], v[3]));
761 }
762 Err(ProjError::OutsideGrid)
763 }
764 }
765 }
766
767 fn has_inverse(&self) -> bool {
768 true
769 }
770}
771
772impl GridShift {
773 fn geog3d_dz(&self, shifts: &[f64]) -> f64 {
776 if self.no_z_transform {
777 return 0.0;
778 }
779 match self.idx_z {
780 Some(i) => shifts.get(i).copied().unwrap_or(0.0),
781 None => 0.0,
782 }
783 }
784}
785
786fn parse_interpolation(p: &TransParams) -> ProjResult<Option<Interpolation>> {
791 match p.params.get_str("interpolation") {
792 None => Ok(None),
793 Some("bilinear") => Ok(Some(Interpolation::Bilinear)),
794 Some("biquadratic") => Ok(Some(Interpolation::Biquadratic)),
795 Some(_) => Err(ProjError::IllegalArgValue),
796 }
797}
798
799fn interpolation_from_metadata(method: Option<&str>) -> ProjResult<Interpolation> {
803 match method {
804 None | Some("") | Some("bilinear") => Ok(Interpolation::Bilinear),
805 Some("biquadratic") => Ok(Interpolation::Biquadratic),
806 Some(_) => Err(ProjError::IllegalArgValue),
807 }
808}
809
810fn build(shift: GridShift) -> TransBuild {
812 TransBuild::new(Box::new(shift), IoUnits::Radians, IoUnits::Radians)
813}
814
815pub fn new(p: &TransParams) -> ProjResult<TransBuild> {
827 let grid_name = p.params.get_str("grids").ok_or(ProjError::MissingArg)?;
828 let interp_param = parse_interpolation(p)?;
829 let no_z_transform = p.params.get_bool("no_z_transform");
830
831 let resolved = resolve_grid_list(p.registry, grid_name)?;
832 if resolved.is_empty() {
834 return Ok(build(GridShift {
835 grids: Vec::new(),
836 kind: ShiftKind::Vertical,
837 hplan: None,
838 idx_z: None,
839 interpolation: interp_param.unwrap_or(Interpolation::Bilinear),
840 no_z_transform,
841 }));
842 }
843
844 let (first_name, first_bytes) = &resolved[0];
847
848 if is_tiff(first_bytes) {
849 return build_geotiff_dispatch(&resolved, interp_param, no_z_transform);
850 }
851
852 let interpolation = interp_param.unwrap_or(Interpolation::Bilinear);
855
856 if let Ok(grids) = read_ntv2(first_bytes, first_name) {
859 if !grids.is_empty() && grids[0].bands.len() >= 2 {
860 let mut all = grids;
861 for (name, bytes) in &resolved[1..] {
862 let more = read_ntv2(bytes, name)?;
863 all.extend(more);
864 }
865 return Ok(build(GridShift {
866 grids: all,
867 kind: ShiftKind::HorizontalArcSec,
868 hplan: None,
869 idx_z: None,
870 interpolation,
871 no_z_transform,
872 }));
873 }
874 }
875
876 if let Ok(gs) = read_gtx(first_bytes, first_name) {
878 if gs.bands.len() == 1 {
879 let mut all = vec![gs];
880 for (name, bytes) in &resolved[1..] {
881 all.push(read_gtx(bytes, name)?);
882 }
883 return Ok(build(GridShift {
884 grids: all,
885 kind: ShiftKind::Vertical,
886 hplan: None,
887 idx_z: None,
888 interpolation,
889 no_z_transform,
890 }));
891 }
892 }
893
894 Err(ProjError::FileNotFound)
895}
896
897fn build_geotiff_dispatch(
905 resolved: &[(String, Vec<u8>)],
906 interp_param: Option<Interpolation>,
907 no_z_transform: bool,
908) -> ProjResult<TransBuild> {
909 let (first_name, first_bytes) = &resolved[0];
910 let first_gs = read_geotiff(first_bytes, first_name)?;
911 let md = read_geotiff_gdal_metadata(first_bytes)?;
912 let type_meta = md.as_ref().and_then(|m| m.get("TYPE").map(str::to_string));
913 let interpolation = match interp_param {
914 Some(i) => i,
915 None => {
916 interpolation_from_metadata(md.as_ref().and_then(|m| m.get("interpolation_method")))?
917 }
918 };
919
920 let expand = |map: &dyn Fn(GridSet) -> ProjResult<GridSet>| -> ProjResult<Vec<GridSet>> {
928 let mut all = Vec::new();
929 for (name, bytes) in resolved {
930 for gs in read_geotiff_hierarchy(bytes, name)? {
931 all.push(map(gs)?);
932 }
933 }
934 Ok(all)
935 };
936
937 match type_meta.as_deref() {
938 Some("HORIZONTAL_OFFSET") => {
939 let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
940 let grids = expand(&|gs| Ok(gs))?;
941 Ok(build(GridShift {
942 grids,
943 kind: ShiftKind::HorizontalGeoTiff,
944 hplan: Some(plan),
945 idx_z: None,
946 interpolation,
947 no_z_transform,
948 }))
949 }
950 Some("GEOGRAPHIC_3D_OFFSET") => {
951 let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
952 let idx_z = resolve_vertical_band(&first_gs).ok_or(ProjError::FileNotFound)?;
953 let grids = expand(&|gs| Ok(gs))?;
954 Ok(build(GridShift {
955 grids,
956 kind: ShiftKind::Geographic3DOffset,
957 hplan: Some(plan),
958 idx_z: Some(idx_z),
959 interpolation,
960 no_z_transform,
961 }))
962 }
963 Some("VERTICAL_OFFSET_GEOGRAPHIC_TO_VERTICAL")
964 | Some("VERTICAL_OFFSET_VERTICAL_TO_VERTICAL")
965 | Some("ELLIPSOIDAL_HEIGHT_OFFSET") => {
966 let grids = expand(&|gs| vertical_gridset(gs))?;
967 Ok(build(GridShift {
968 grids,
969 kind: ShiftKind::Vertical,
970 hplan: None,
971 idx_z: None,
972 interpolation,
973 no_z_transform,
974 }))
975 }
976 Some(_) => Err(ProjError::UnsupportedOperation),
980 None => match first_gs.bands.len() {
982 2 => {
983 let plan = resolve_hshift_plan(&first_gs).ok_or(ProjError::UnsupportedOperation)?;
984 let grids = expand(&|gs| Ok(gs))?;
985 Ok(build(GridShift {
986 grids,
987 kind: ShiftKind::HorizontalGeoTiff,
988 hplan: Some(plan),
989 idx_z: None,
990 interpolation,
991 no_z_transform,
992 }))
993 }
994 1 => {
995 let grids = expand(&|gs| Ok(gs))?;
996 Ok(build(GridShift {
997 grids,
998 kind: ShiftKind::Vertical,
999 hplan: None,
1000 idx_z: None,
1001 interpolation,
1002 no_z_transform,
1003 }))
1004 }
1005 _ => {
1006 let grids = expand(&|gs| Ok(gs))?;
1007 Ok(build(GridShift {
1008 grids,
1009 kind: ShiftKind::Xyz,
1010 hplan: None,
1011 idx_z: None,
1012 interpolation,
1013 no_z_transform,
1014 }))
1015 }
1016 },
1017 }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023 use oxiproj_core::DEG_TO_RAD;
1024
1025 fn build_ntv2(lat_shift: f32, lon_shift: f32) -> Vec<u8> {
1026 let mut buf = Vec::new();
1028 buf.extend_from_slice(b"NUM_OREC");
1029 buf.extend_from_slice(&11i32.to_le_bytes());
1030 buf.extend_from_slice(&[0u8; 4]);
1031 buf.extend_from_slice(b"NUM_SREC");
1032 buf.extend_from_slice(&11i32.to_le_bytes());
1033 buf.extend_from_slice(&[0u8; 4]);
1034 buf.extend_from_slice(b"NUM_FILE");
1035 buf.extend_from_slice(&1u32.to_le_bytes());
1036 buf.extend_from_slice(&[0u8; 4]);
1037 buf.extend_from_slice(b"GS_TYPE ");
1038 buf.extend_from_slice(b"SECONDS ");
1039 buf.extend_from_slice(&[0u8; 112]);
1040 buf.extend_from_slice(b"SUB_NAME");
1041 buf.extend_from_slice(b"TESTGRID");
1042 buf.extend_from_slice(b"PARENT ");
1043 buf.extend_from_slice(b"NONE ");
1044 buf.extend_from_slice(b"CREATED ");
1045 buf.extend_from_slice(b"20240101");
1046 buf.extend_from_slice(b"UPDATED ");
1047 buf.extend_from_slice(b"20240101");
1048 buf.extend_from_slice(b"S_LAT ");
1049 buf.extend_from_slice(&0.0f64.to_le_bytes());
1050 buf.extend_from_slice(b"N_LAT ");
1051 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1052 buf.extend_from_slice(b"E_LONG ");
1053 buf.extend_from_slice(&0.0f64.to_le_bytes());
1054 buf.extend_from_slice(b"W_LONG ");
1055 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1056 buf.extend_from_slice(b"LAT_INC ");
1057 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1058 buf.extend_from_slice(b"LONG_INC");
1059 buf.extend_from_slice(&3600.0f64.to_le_bytes());
1060 buf.extend_from_slice(b"GS_COUNT");
1061 buf.extend_from_slice(&4i32.to_le_bytes());
1062 buf.extend_from_slice(&[0u8; 4]);
1063 for _ in 0..4 {
1064 buf.extend_from_slice(&lat_shift.to_le_bytes());
1065 buf.extend_from_slice(&lon_shift.to_le_bytes());
1066 buf.extend_from_slice(&0.0f32.to_le_bytes());
1067 buf.extend_from_slice(&0.0f32.to_le_bytes());
1068 }
1069 buf
1070 }
1071
1072 fn build_gtx(shift: f32) -> Vec<u8> {
1073 let mut buf = Vec::new();
1074 buf.extend_from_slice(&0.0f64.to_be_bytes()); buf.extend_from_slice(&0.0f64.to_be_bytes()); buf.extend_from_slice(&1.0f64.to_be_bytes()); buf.extend_from_slice(&1.0f64.to_be_bytes()); buf.extend_from_slice(&2i32.to_be_bytes()); buf.extend_from_slice(&2i32.to_be_bytes()); for _ in 0..4 {
1081 buf.extend_from_slice(&shift.to_be_bytes());
1082 }
1083 buf
1084 }
1085
1086 struct InMemReg(std::collections::HashMap<String, Vec<u8>>);
1087 impl crate::GridRegistry for InMemReg {
1088 fn get_grid(&self, name: &str) -> Option<&[u8]> {
1089 self.0.get(name).map(|v| v.as_slice())
1090 }
1091 }
1092
1093 struct TestParams {
1094 grids: String,
1095 }
1096 impl crate::TransParamLookup for TestParams {
1097 fn get_str(&self, key: &str) -> Option<&str> {
1098 if key == "grids" {
1099 Some(&self.grids)
1100 } else {
1101 None
1102 }
1103 }
1104 fn get_f64(&self, _: &str) -> Option<f64> {
1105 None
1106 }
1107 fn get_dms(&self, _: &str) -> Option<f64> {
1108 None
1109 }
1110 fn get_int(&self, _: &str) -> Option<i64> {
1111 None
1112 }
1113 fn get_bool(&self, _: &str) -> bool {
1114 false
1115 }
1116 fn exists(&self, key: &str) -> bool {
1117 key == "grids"
1118 }
1119 }
1120
1121 #[test]
1122 fn test_gridshift_detects_ntv2() {
1123 let data = build_ntv2(1800.0, 900.0);
1125 let mut map = std::collections::HashMap::new();
1126 map.insert("grid.gsb".to_string(), data);
1127 let reg = InMemReg(map);
1128 let tp = TestParams {
1129 grids: "grid.gsb".to_string(),
1130 };
1131 let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1132 let p = crate::TransParams {
1133 ellipsoid: &ell,
1134 params: &tp,
1135 registry: Some(®),
1136 };
1137 let tb = new(&p).unwrap();
1138 let input = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1140 let out = tb.operation.forward_4d(input).unwrap();
1141 let expected_lat = (0.5 + 0.5) * DEG_TO_RAD;
1145 let expected_lon = (-0.5 - 0.25) * DEG_TO_RAD;
1146 assert!((out.v()[1] - expected_lat).abs() < 1e-9, "lat");
1147 assert!((out.v()[0] - expected_lon).abs() < 1e-9, "lon");
1148 }
1149
1150 #[test]
1151 fn test_gridshift_detects_gtx() {
1152 let data = build_gtx(5.0);
1154 let mut map = std::collections::HashMap::new();
1155 map.insert("grid.gtx".to_string(), data);
1156 let reg = InMemReg(map);
1157 let tp = TestParams {
1158 grids: "grid.gtx".to_string(),
1159 };
1160 let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1161 let p = crate::TransParams {
1162 ellipsoid: &ell,
1163 params: &tp,
1164 registry: Some(®),
1165 };
1166 let tb = new(&p).unwrap();
1167 let input = Coord::new(0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
1168 let out = tb.operation.forward_4d(input).unwrap();
1169 assert!(
1173 (out.v()[2] - 5.0).abs() < 1e-6,
1174 "z should be +5.0 (generic gridshift forward adds), got {}",
1175 out.v()[2]
1176 );
1177 }
1178
1179 #[test]
1180 fn resolve_grid_bytes_registry_first_then_file_not_found() {
1181 let mut map = std::collections::HashMap::new();
1185 map.insert("reg.gsb".to_string(), vec![1u8, 2, 3]);
1186 let reg = InMemReg(map);
1187 assert_eq!(
1188 resolve_grid_bytes(Some(®), "reg.gsb").unwrap(),
1189 vec![1u8, 2, 3]
1190 );
1191 assert_eq!(
1192 resolve_grid_bytes(Some(®), "e4_absent_grid_9d3f7a.gsb").err(),
1193 Some(ProjError::FileNotFound)
1194 );
1195 assert_eq!(
1196 resolve_grid_bytes(None, "e4_absent_grid_9d3f7a.gsb").err(),
1197 Some(ProjError::FileNotFound)
1198 );
1199 }
1200
1201 #[test]
1202 fn test_gridshift_round_trip_ntv2() {
1203 let data = build_ntv2(1800.0, 900.0);
1205 let mut map = std::collections::HashMap::new();
1206 map.insert("grid.gsb".to_string(), data);
1207 let reg = InMemReg(map);
1208 let tp = TestParams {
1209 grids: "grid.gsb".to_string(),
1210 };
1211 let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1212 let p = crate::TransParams {
1213 ellipsoid: &ell,
1214 params: &tp,
1215 registry: Some(®),
1216 };
1217 let tb = new(&p).unwrap();
1218 let input = Coord::new(-0.7 * DEG_TO_RAD, 0.4 * DEG_TO_RAD, 0.0, 0.0);
1220 let fwd = tb.operation.forward_4d(input).unwrap();
1221 let inv = tb.operation.inverse_4d(fwd).unwrap();
1222 let vi = inv.v();
1223 let vi0 = input.v();
1224 assert!((vi[0] - vi0[0]).abs() < 1e-9, "lon round-trip");
1225 assert!((vi[1] - vi0[1]).abs() < 1e-9, "lat round-trip");
1226 }
1227
1228 use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1234
1235 fn geotiff_hshift_gridset(
1238 lat_role: BandRole,
1239 lat_unit: BandUnit,
1240 lat_val: f32,
1241 lon_role: BandRole,
1242 lon_unit: BandUnit,
1243 lon_positive: BandPositive,
1244 lon_val: f32,
1245 ) -> GridSet {
1246 let extent = GridExtent {
1247 ll_lat: 39.0,
1248 ll_lon: -81.0,
1249 ur_lat: 41.0,
1250 ur_lon: -79.0,
1251 lat_inc: 2.0,
1252 lon_inc: 2.0,
1253 };
1254 let band0 = GridBand {
1255 extent: extent.clone(),
1256 values: vec![lat_val; 4],
1257 semantics: BandSemantics {
1258 role: lat_role,
1259 unit: lat_unit,
1260 positive: BandPositive::Unknown,
1261 },
1262 };
1263 let band1 = GridBand {
1264 extent,
1265 values: vec![lon_val; 4],
1266 semantics: BandSemantics {
1267 role: lon_role,
1268 unit: lon_unit,
1269 positive: lon_positive,
1270 },
1271 };
1272 GridSet {
1273 bands: vec![band0, band1],
1274 source: GridSource::GeoTiff {
1275 path: "mem.tif".into(),
1276 },
1277 }
1278 }
1279
1280 #[test]
1281 fn geotiff_hshift_no_band_swap_arcsec_positive_east() {
1282 let gs = geotiff_hshift_gridset(
1285 BandRole::LatitudeOffset,
1286 BandUnit::ArcSecond,
1287 3600.0,
1288 BandRole::LongitudeOffset,
1289 BandUnit::ArcSecond,
1290 BandPositive::East,
1291 3600.0,
1292 );
1293 let tb = build_horizontal_geotiff(gs).unwrap();
1294 let out = tb
1295 .operation
1296 .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1297 .unwrap();
1298 let v = out.v();
1299 assert!(
1300 (v[1].to_degrees() - 41.0).abs() < 1e-9,
1301 "lat += band0 (no swap): {}",
1302 v[1].to_degrees()
1303 );
1304 assert!(
1305 (v[0].to_degrees() - (-79.0)).abs() < 1e-9,
1306 "lon += band1 east: {}",
1307 v[0].to_degrees()
1308 );
1309 }
1310
1311 #[test]
1312 fn geotiff_hshift_positive_west_negates_longitude() {
1313 let gs = geotiff_hshift_gridset(
1315 BandRole::LatitudeOffset,
1316 BandUnit::ArcSecond,
1317 0.0,
1318 BandRole::LongitudeOffset,
1319 BandUnit::ArcSecond,
1320 BandPositive::West,
1321 3600.0,
1322 );
1323 let tb = build_horizontal_geotiff(gs).unwrap();
1324 let out = tb
1325 .operation
1326 .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1327 .unwrap();
1328 let v = out.v();
1329 assert!(
1330 (v[0].to_degrees() - (-81.0)).abs() < 1e-9,
1331 "lon -= band1 (positive west): {}",
1332 v[0].to_degrees()
1333 );
1334 }
1335
1336 #[test]
1337 fn geotiff_hshift_degree_unit_and_default_band_order() {
1338 let gs = geotiff_hshift_gridset(
1342 BandRole::Unknown,
1343 BandUnit::Degree,
1344 0.5,
1345 BandRole::Unknown,
1346 BandUnit::Degree,
1347 BandPositive::Unknown,
1348 0.5,
1349 );
1350 let tb = build_horizontal_geotiff(gs).unwrap();
1351 let out = tb
1352 .operation
1353 .forward_4d(Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 0.0, 0.0))
1354 .unwrap();
1355 let v = out.v();
1356 assert!((v[1].to_degrees() - 40.5).abs() < 1e-9, "lat default band0");
1357 assert!(
1358 (v[0].to_degrees() - (-79.5)).abs() < 1e-9,
1359 "lon default band1"
1360 );
1361 }
1362
1363 #[test]
1364 fn geotiff_hshift_round_trips() {
1365 let gs = geotiff_hshift_gridset(
1366 BandRole::LatitudeOffset,
1367 BandUnit::ArcSecond,
1368 120.0,
1369 BandRole::LongitudeOffset,
1370 BandUnit::ArcSecond,
1371 BandPositive::East,
1372 -240.0,
1373 );
1374 let tb = build_horizontal_geotiff(gs).unwrap();
1375 let input = Coord::new(-80.0 * DEG_TO_RAD, 40.0 * DEG_TO_RAD, 7.0, 0.0);
1376 let fwd = tb.operation.forward_4d(input).unwrap();
1377 let inv = tb.operation.inverse_4d(fwd).unwrap();
1378 assert!((inv.v()[0] - input.v()[0]).abs() < 1e-11, "lon round-trip");
1379 assert!((inv.v()[1] - input.v()[1]).abs() < 1e-11, "lat round-trip");
1380 }
1381
1382 #[test]
1383 fn resolve_hshift_plan_rejects_single_offset_channel() {
1384 let gs = geotiff_hshift_gridset(
1386 BandRole::LatitudeOffset,
1387 BandUnit::ArcSecond,
1388 1.0,
1389 BandRole::Unknown,
1390 BandUnit::ArcSecond,
1391 BandPositive::Unknown,
1392 1.0,
1393 );
1394 assert!(resolve_hshift_plan(&gs).is_none());
1395 }
1396
1397 struct InterpParams {
1402 interpolation: Option<String>,
1403 no_z: bool,
1404 }
1405 impl crate::TransParamLookup for InterpParams {
1406 fn get_str(&self, key: &str) -> Option<&str> {
1407 match key {
1408 "grids" => Some("grid.gtx"),
1409 "interpolation" => self.interpolation.as_deref(),
1410 _ => None,
1411 }
1412 }
1413 fn get_f64(&self, _: &str) -> Option<f64> {
1414 None
1415 }
1416 fn get_dms(&self, _: &str) -> Option<f64> {
1417 None
1418 }
1419 fn get_int(&self, _: &str) -> Option<i64> {
1420 None
1421 }
1422 fn get_bool(&self, key: &str) -> bool {
1423 key == "no_z_transform" && self.no_z
1424 }
1425 fn exists(&self, key: &str) -> bool {
1426 key == "grids"
1427 || (key == "interpolation" && self.interpolation.is_some())
1428 || (key == "no_z_transform" && self.no_z)
1429 }
1430 }
1431
1432 fn interp_of(s: Option<&str>) -> ProjResult<Option<Interpolation>> {
1433 let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
1434 let tp = InterpParams {
1435 interpolation: s.map(str::to_string),
1436 no_z: false,
1437 };
1438 let p = TransParams {
1439 ellipsoid: &ell,
1440 params: &tp,
1441 registry: None,
1442 };
1443 parse_interpolation(&p)
1444 }
1445
1446 #[test]
1447 fn parse_interpolation_maps_and_rejects() {
1448 assert_eq!(interp_of(None).unwrap(), None);
1450 assert_eq!(
1451 interp_of(Some("bilinear")).unwrap(),
1452 Some(Interpolation::Bilinear)
1453 );
1454 assert_eq!(
1455 interp_of(Some("biquadratic")).unwrap(),
1456 Some(Interpolation::Biquadratic)
1457 );
1458 assert_eq!(
1459 interp_of(Some("bicubic")).err(),
1460 Some(ProjError::IllegalArgValue)
1461 );
1462 }
1463
1464 #[test]
1465 fn interpolation_from_metadata_maps_and_rejects() {
1466 assert_eq!(
1467 interpolation_from_metadata(None).unwrap(),
1468 Interpolation::Bilinear
1469 );
1470 assert_eq!(
1471 interpolation_from_metadata(Some("bilinear")).unwrap(),
1472 Interpolation::Bilinear
1473 );
1474 assert_eq!(
1475 interpolation_from_metadata(Some("biquadratic")).unwrap(),
1476 Interpolation::Biquadratic
1477 );
1478 assert_eq!(
1479 interpolation_from_metadata(Some("nearest")).err(),
1480 Some(ProjError::IllegalArgValue)
1481 );
1482 }
1483
1484 fn quadratic_gridset() -> GridSet {
1488 use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1489 let n = 5usize;
1490 let g = |col: f64, srow: f64| {
1491 1.0 + 2.0 * col - 3.0 * srow + 0.5 * col * col + 0.25 * srow * srow
1492 };
1493 let mut values = Vec::with_capacity(n * n);
1494 for north_row in 0..n {
1495 let srow = (n - 1 - north_row) as f64;
1496 for col in 0..n {
1497 values.push(g(col as f64, srow) as f32);
1498 }
1499 }
1500 let extent = GridExtent {
1501 ll_lat: 0.0,
1502 ll_lon: 0.0,
1503 ur_lat: (n - 1) as f64,
1504 ur_lon: (n - 1) as f64,
1505 lat_inc: 1.0,
1506 lon_inc: 1.0,
1507 };
1508 GridSet {
1509 bands: vec![GridBand {
1510 extent,
1511 values,
1512 semantics: BandSemantics::default(),
1513 }],
1514 source: GridSource::Memory,
1515 }
1516 }
1517
1518 #[test]
1519 fn biquadratic_reproduces_quadratic_exactly() {
1520 let gs = quadratic_gridset();
1521 let g = |col: f64, srow: f64| {
1522 1.0 + 2.0 * col - 3.0 * srow + 0.5 * col * col + 0.25 * srow * srow
1523 };
1524 let got = sample_grid_biquadratic(&gs, 1.7, 2.3).unwrap()[0];
1526 let expected = g(2.3, 1.7);
1527 assert!(
1528 (got - expected).abs() < 1e-6,
1529 "biquadratic exact for quadratic: got {got}, expected {expected}"
1530 );
1531 let bilinear = sample_grid(&gs, 1.7, 2.3).unwrap()[0];
1534 assert!(
1535 (bilinear - expected).abs() > 1e-3,
1536 "bilinear should differ from the quadratic truth"
1537 );
1538 }
1539
1540 #[test]
1541 fn biquadratic_falls_back_to_bilinear_for_small_grids() {
1542 use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1543 let extent = GridExtent {
1545 ll_lat: 0.0,
1546 ll_lon: 0.0,
1547 ur_lat: 1.0,
1548 ur_lon: 1.0,
1549 lat_inc: 1.0,
1550 lon_inc: 1.0,
1551 };
1552 let gs = GridSet {
1553 bands: vec![GridBand {
1554 extent,
1555 values: vec![1.0, 2.0, 3.0, 4.0],
1556 semantics: BandSemantics::default(),
1557 }],
1558 source: GridSource::Memory,
1559 };
1560 let bq = sample_grid_biquadratic(&gs, 0.5, 0.5).unwrap()[0];
1561 let bl = sample_grid(&gs, 0.5, 0.5).unwrap()[0];
1562 assert!(
1563 (bq - bl).abs() < 1e-12,
1564 "2×2 biquadratic falls back to bilinear"
1565 );
1566 }
1567
1568 #[test]
1573 fn resolve_grid_list_honors_optional_prefix_and_order() {
1574 let mut map = std::collections::HashMap::new();
1575 map.insert("a.gsb".to_string(), vec![1u8]);
1576 map.insert("b.gsb".to_string(), vec![2u8]);
1577 let reg = InMemReg(map);
1578
1579 let list = resolve_grid_list(Some(®), "@missing.gsb,b.gsb").unwrap();
1581 assert_eq!(list.len(), 1);
1582 assert_eq!(list[0].0, "b.gsb");
1583
1584 let list = resolve_grid_list(Some(®), "a.gsb,b.gsb").unwrap();
1586 assert_eq!(
1587 list.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(),
1588 ["a.gsb", "b.gsb"]
1589 );
1590
1591 assert_eq!(
1593 resolve_grid_list(Some(®), "a.gsb,missing.gsb").err(),
1594 Some(ProjError::FileNotFound)
1595 );
1596
1597 assert!(resolve_grid_list(Some(®), "@x.gsb,@y.gsb")
1599 .unwrap()
1600 .is_empty());
1601 }
1602
1603 #[test]
1608 fn resolve_vertical_band_prefers_geoid_role() {
1609 use oxiproj_grids::{BandSemantics, GridBand, GridExtent, GridSource};
1610 let extent = GridExtent {
1611 ll_lat: 0.0,
1612 ll_lon: 0.0,
1613 ur_lat: 1.0,
1614 ur_lon: 1.0,
1615 lat_inc: 1.0,
1616 lon_inc: 1.0,
1617 };
1618 let band = |role: BandRole| GridBand {
1619 extent: extent.clone(),
1620 values: vec![0.0; 4],
1621 semantics: BandSemantics {
1622 role,
1623 unit: BandUnit::Metre,
1624 positive: BandPositive::Unknown,
1625 },
1626 };
1627 let gs = GridSet {
1629 bands: vec![band(BandRole::Unknown), band(BandRole::GeoidUndulation)],
1630 source: GridSource::Memory,
1631 };
1632 assert_eq!(resolve_vertical_band(&gs), Some(1));
1633 let reduced = vertical_gridset(gs).unwrap();
1634 assert_eq!(reduced.bands.len(), 1);
1635 assert_eq!(reduced.bands[0].semantics.role, BandRole::GeoidUndulation);
1636
1637 let gs2 = GridSet {
1639 bands: vec![band(BandRole::Unknown)],
1640 source: GridSource::Memory,
1641 };
1642 assert_eq!(resolve_vertical_band(&gs2), Some(0));
1643 }
1644
1645 #[test]
1650 fn time_gate_matches_proj_condition() {
1651 assert!(time_gate_applies(0.0, 0.0, 1234.0));
1653 assert!(time_gate_applies(2000.0, 0.0, 3000.0));
1654 assert!(time_gate_applies(0.0, 2010.0, 3000.0));
1655 assert!(time_gate_applies(2000.0, 2010.0, 1995.0));
1657 assert!(!time_gate_applies(2000.0, 2010.0, 2005.0));
1658 assert!(!time_gate_applies(2000.0, 2010.0, 2000.0));
1659 assert!(!time_gate_applies(2010.0, 2000.0, 1995.0));
1661 }
1662
1663 #[test]
1664 fn civil_date_round_trips_known_days() {
1665 assert_eq!(civil_from_days(18_321), (2020, 2, 29));
1667 assert_eq!(day_of_year(2020, 1, 1), 0);
1669 assert_eq!(day_of_year(2020, 2, 29), 59);
1670 assert_eq!(day_of_year(2021, 3, 1), 59); }
1672}