oxiproj-transformations 0.1.1

Datum transformations and coordinate conversions for OxiProj.
Documentation
#![forbid(unsafe_code)]
//! Horizontal grid shift (`hgridshift`) — port of PROJ `src/transformations/hgridshift.cpp`.
//! Reads an NTv2 binary grid and applies bilinear-interpolated lat/lon shifts
//! (stored in arc-seconds) to input coordinates in radians.

use crate::{TransBuild, TransParams};
use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
use oxiproj_grids::{read_ntv2, sample_grid, GridSet};
use std::f64::consts::PI;

/// Arc-seconds to radians: π / (180 × 3600) = π / 648000
const ARCSEC_TO_RAD: f64 = PI / 648_000.0;
const MAX_ITER: usize = 10;
const ITER_TOL: f64 = 1e-12;

#[derive(Debug)]
struct HGridShift {
    grids: Vec<GridSet>,
}

impl Operation for HGridShift {
    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
        let v = c.v();
        let lon_deg = v[0].to_degrees();
        let lat_deg = v[1].to_degrees();
        for gs in &self.grids {
            if let Some(shifts) = sample_grid(gs, lat_deg, lon_deg) {
                // shifts[0]=lat shift arcsec, shifts[1]=lon shift arcsec
                let lat_r = shifts[0] * ARCSEC_TO_RAD;
                let lon_r = shifts[1] * ARCSEC_TO_RAD;
                return Ok(Coord::new(v[0] + lon_r, v[1] + lat_r, v[2], v[3]));
            }
        }
        Err(ProjError::OutsideGrid)
    }

    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
        let v = c.v();
        let mut lon_r = v[0];
        let mut lat_r = v[1];
        for gs in &self.grids {
            // Check if the point falls in this grid (approximate).
            let lon_deg0 = lon_r.to_degrees();
            let lat_deg0 = lat_r.to_degrees();
            if sample_grid(gs, lat_deg0, lon_deg0).is_none() {
                continue;
            }
            // Iterative fixed-point inversion.
            for _ in 0..MAX_ITER {
                let lon_deg_try = lon_r.to_degrees();
                let lat_deg_try = lat_r.to_degrees();
                let shifts = match sample_grid(gs, lat_deg_try, lon_deg_try) {
                    Some(s) => s,
                    None => break,
                };
                let new_lon = v[0] - shifts[1] * ARCSEC_TO_RAD;
                let new_lat = v[1] - shifts[0] * ARCSEC_TO_RAD;
                let dlon = (new_lon - lon_r).abs();
                let dlat = (new_lat - lat_r).abs();
                lon_r = new_lon;
                lat_r = new_lat;
                if dlon < ITER_TOL && dlat < ITER_TOL {
                    break;
                }
            }
            return Ok(Coord::new(lon_r, lat_r, v[2], v[3]));
        }
        Err(ProjError::OutsideGrid)
    }

    fn has_inverse(&self) -> bool {
        true
    }
}

/// Construct an `hgridshift` transform from parsed parameters.
///
/// Requires `+grids=<name>` and a populated [`GridRegistry`](crate::GridRegistry)
/// in `p.registry`. The named grid is parsed as NTv2.
pub fn new(p: &TransParams) -> ProjResult<TransBuild> {
    let grid_name = p.params.get_str("grids").ok_or(ProjError::MissingArg)?;
    let registry = p.registry.ok_or(ProjError::FileNotFound)?;
    let data = registry
        .get_grid(grid_name)
        .ok_or(ProjError::FileNotFound)?;
    let grids = read_ntv2(data, grid_name)?;
    if grids.is_empty() {
        return Err(ProjError::FileNotFound);
    }
    for gs in &grids {
        if gs.bands.len() < 2 {
            return Err(ProjError::FileNotFound);
        }
    }
    Ok(TransBuild::new(
        Box::new(HGridShift { grids }),
        IoUnits::Radians,
        IoUnits::Radians,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxiproj_core::DEG_TO_RAD;

    // Build a minimal NTv2 file with uniform lat_shift and lon_shift (arc-seconds),
    // covering ll=(0°,0°) to ur=(1°,-1°) (positive-west convention for NTv2).
    fn build_ntv2_with_shifts(lat_shift: f32, lon_shift: f32) -> Vec<u8> {
        let mut buf = Vec::new();
        // File header (11 × 16 = 176 bytes)
        buf.extend_from_slice(b"NUM_OREC");
        buf.extend_from_slice(&11i32.to_le_bytes());
        buf.extend_from_slice(&[0u8; 4]);
        buf.extend_from_slice(b"NUM_SREC");
        buf.extend_from_slice(&11i32.to_le_bytes());
        buf.extend_from_slice(&[0u8; 4]);
        buf.extend_from_slice(b"NUM_FILE");
        buf.extend_from_slice(&1u32.to_le_bytes());
        buf.extend_from_slice(&[0u8; 4]);
        buf.extend_from_slice(b"GS_TYPE ");
        buf.extend_from_slice(b"SECONDS ");
        buf.extend_from_slice(&[0u8; 112]);
        assert_eq!(buf.len(), 176);

        // Sub-grid header (11 × 16 = 176 bytes)
        buf.extend_from_slice(b"SUB_NAME");
        buf.extend_from_slice(b"TESTGRID");
        buf.extend_from_slice(b"PARENT  ");
        buf.extend_from_slice(b"NONE    ");
        buf.extend_from_slice(b"CREATED ");
        buf.extend_from_slice(b"20240101");
        buf.extend_from_slice(b"UPDATED ");
        buf.extend_from_slice(b"20240101");
        buf.extend_from_slice(b"S_LAT   ");
        buf.extend_from_slice(&0.0f64.to_le_bytes());
        buf.extend_from_slice(b"N_LAT   ");
        buf.extend_from_slice(&3600.0f64.to_le_bytes());
        buf.extend_from_slice(b"E_LONG  ");
        buf.extend_from_slice(&0.0f64.to_le_bytes());
        buf.extend_from_slice(b"W_LONG  ");
        buf.extend_from_slice(&3600.0f64.to_le_bytes());
        buf.extend_from_slice(b"LAT_INC ");
        buf.extend_from_slice(&3600.0f64.to_le_bytes());
        buf.extend_from_slice(b"LONG_INC");
        buf.extend_from_slice(&3600.0f64.to_le_bytes());
        buf.extend_from_slice(b"GS_COUNT");
        buf.extend_from_slice(&4i32.to_le_bytes());
        buf.extend_from_slice(&[0u8; 4]);
        assert_eq!(buf.len(), 352);

        // Data: 4 cells (2×2), uniform shift values
        for _ in 0..4 {
            buf.extend_from_slice(&lat_shift.to_le_bytes());
            buf.extend_from_slice(&lon_shift.to_le_bytes());
            buf.extend_from_slice(&0.0f32.to_le_bytes()); // accuracy lat
            buf.extend_from_slice(&0.0f32.to_le_bytes()); // accuracy lon
        }
        buf
    }

    // Test-only registry
    struct InMemReg(std::collections::HashMap<String, Vec<u8>>);
    impl crate::GridRegistry for InMemReg {
        fn get_grid(&self, name: &str) -> Option<&[u8]> {
            self.0.get(name).map(|v| v.as_slice())
        }
    }

    // Minimal TransParamLookup impl for tests
    struct TestParams {
        grids: String,
    }
    impl crate::TransParamLookup for TestParams {
        fn get_str(&self, key: &str) -> Option<&str> {
            if key == "grids" {
                Some(&self.grids)
            } else {
                None
            }
        }
        fn get_f64(&self, _key: &str) -> Option<f64> {
            None
        }
        fn get_dms(&self, _key: &str) -> Option<f64> {
            None
        }
        fn get_int(&self, _key: &str) -> Option<i64> {
            None
        }
        fn get_bool(&self, _key: &str) -> bool {
            false
        }
        fn exists(&self, key: &str) -> bool {
            key == "grids"
        }
    }

    #[test]
    fn test_hgridshift_forward_applies_arcsec_shift() {
        let data = build_ntv2_with_shifts(3600.0, 1800.0); // 1 deg lat, 0.5 deg lon
        let mut map = std::collections::HashMap::new();
        map.insert("test.gsb".to_string(), data);
        let reg = InMemReg(map);
        let tp = TestParams {
            grids: "test.gsb".to_string(),
        };
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &tp,
            registry: Some(&reg),
        };
        let tb = new(&params).unwrap();
        // Point at lon=-0.5°, lat=0.5° (inside the grid: lat 0-1°, lon -1-0°)
        let input = Coord::new(-0.5 * DEG_TO_RAD, 0.5 * DEG_TO_RAD, 0.0, 0.0);
        let out = tb.operation.forward_4d(input).unwrap();
        let v = out.v();
        // lon shift = 1800 arcsec * ARCSEC_TO_RAD = 0.5 deg in rad
        let expected_lon = (-0.5 + 0.5) * DEG_TO_RAD;
        let expected_lat = (0.5 + 1.0) * DEG_TO_RAD;
        assert!(
            (v[0] - expected_lon).abs() < 1e-9,
            "lon: got {}, expected {}",
            v[0],
            expected_lon
        );
        assert!(
            (v[1] - expected_lat).abs() < 1e-9,
            "lat: got {}, expected {}",
            v[1],
            expected_lat
        );
    }

    #[test]
    fn test_hgridshift_round_trip() {
        let data = build_ntv2_with_shifts(1800.0, 900.0);
        let mut map = std::collections::HashMap::new();
        map.insert("test.gsb".to_string(), data);
        let reg = InMemReg(map);
        let tp = TestParams {
            grids: "test.gsb".to_string(),
        };
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &tp,
            registry: Some(&reg),
        };
        let tb = new(&params).unwrap();
        // Point inside the grid: lat 0-1°, lon -1-0°
        let input = Coord::new(-0.7 * DEG_TO_RAD, 0.4 * DEG_TO_RAD, 5.0, 2020.0);
        let fwd = tb.operation.forward_4d(input).unwrap();
        let inv = tb.operation.inverse_4d(fwd).unwrap();
        let vi = inv.v();
        let vi0 = input.v();
        assert!((vi[0] - vi0[0]).abs() < 1e-9, "lon round-trip error");
        assert!((vi[1] - vi0[1]).abs() < 1e-9, "lat round-trip error");
    }

    #[test]
    fn test_hgridshift_outside_returns_err() {
        let data = build_ntv2_with_shifts(1.0, 1.0);
        let mut map = std::collections::HashMap::new();
        map.insert("test.gsb".to_string(), data);
        let reg = InMemReg(map);
        let tp = TestParams {
            grids: "test.gsb".to_string(),
        };
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &tp,
            registry: Some(&reg),
        };
        let tb = new(&params).unwrap();
        // Point far outside grid (grid covers lat 0-1, lon -1 to 0)
        let far = Coord::new(45.0 * DEG_TO_RAD, 45.0 * DEG_TO_RAD, 0.0, 0.0);
        assert_eq!(
            tb.operation.forward_4d(far).err(),
            Some(ProjError::OutsideGrid)
        );
    }

    #[test]
    fn test_hgridshift_missing_grids_param() {
        struct NoParams;
        impl crate::TransParamLookup for NoParams {
            fn get_str(&self, _: &str) -> Option<&str> {
                None
            }
            fn get_f64(&self, _: &str) -> Option<f64> {
                None
            }
            fn get_dms(&self, _: &str) -> Option<f64> {
                None
            }
            fn get_int(&self, _: &str) -> Option<i64> {
                None
            }
            fn get_bool(&self, _: &str) -> bool {
                false
            }
            fn exists(&self, _: &str) -> bool {
                false
            }
        }
        let reg = InMemReg(std::collections::HashMap::new());
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let np = NoParams;
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &np,
            registry: Some(&reg),
        };
        assert_eq!(new(&params).err(), Some(ProjError::MissingArg));
    }
}