oxiproj-transformations 0.1.1

Datum transformations and coordinate conversions for OxiProj.
Documentation
//! Epoch-aware coordinate propagation using plate-motion models.
//!
//! Implements the foundation for T3.1 — automatic epoch-aware pathfinding.
//! Provides [`propagate_epoch`] for advancing ECEF coordinates through time
//! using a specified plate-motion model, and [`plate_for_frame`] for
//! heuristic frame-name to plate association.

use crate::frame_chain::{
    apply_frame_transform, apply_frame_transform_inverse, find_frame_transform,
};
use oxiproj_core::{
    epoch::Epoch,
    plate_motion::{Plate, PlateMotionModel},
    ProjError, ProjResult,
};

/// Advance ECEF coordinates from one epoch to another using a plate-motion model.
///
/// Applies the velocity field encoded in `model` to the given geocentric
/// Cartesian coordinates `(x, y, z)`, propagating them from `from_epoch` to
/// `to_epoch`. The time delta is derived from the difference in decimal years
/// between the two epochs.
///
/// # Arguments
///
/// * `x` — ECEF X coordinate in metres.
/// * `y` — ECEF Y coordinate in metres.
/// * `z` — ECEF Z coordinate in metres.
/// * `from_epoch` — The epoch at which `(x, y, z)` is expressed.
/// * `to_epoch` — The target epoch to propagate the coordinate to.
/// * `model` — The plate-motion model to use for the propagation.
/// * `plate` — The tectonic plate on which the point lies.
///
/// # Errors
///
/// Returns [`ProjError::NoOperation`] when `model` contains no velocity
/// parameters for the specified `plate` (i.e., the model returns `None`).
///
/// # Examples
///
/// ```no_run
/// use oxiproj_core::epoch::Epoch;
/// use oxiproj_core::plate_motion::{Plate, PlateMotionModel};
/// use oxiproj_transformations::epoch_transform::propagate_epoch;
///
/// let from = Epoch::from_decimal_year(2015.0).unwrap();
/// let to   = Epoch::from_decimal_year(2025.0).unwrap();
/// let (x2, y2, z2) = propagate_epoch(
///     3_900_000.0, 900_000.0, 4_900_000.0,
///     &from, &to,
///     PlateMotionModel::Itrf2014, Plate::Eurasian,
/// ).unwrap();
/// ```
pub fn propagate_epoch(
    x: f64,
    y: f64,
    z: f64,
    from_epoch: &Epoch,
    to_epoch: &Epoch,
    model: PlateMotionModel,
    plate: Plate,
) -> ProjResult<(f64, f64, f64)> {
    match model.propagate(plate, x, y, z, from_epoch.year, to_epoch.year) {
        Some(result) => Ok(result),
        None => Err(ProjError::NoOperation),
    }
}

/// Heuristically map a reference-frame name string to a [`Plate`].
///
/// Performs a case-insensitive substring search on `frame_name` to identify
/// which tectonic plate a given reference-frame string is associated with.
/// This is a best-effort lookup intended for PROJ pipeline initialisation and
/// epoch-aware path selection; it should not be used as a substitute for a
/// proper authority-database lookup.
///
/// Returns `None` when no pattern matches.
///
/// # Pattern priority
///
/// Patterns are evaluated in the order listed below; the first match wins.
///
/// | Pattern | Returns |
/// |---------|---------|
/// | `"noam"` or `"north_am"` | [`Plate::NorthAmerican`] |
/// | `"euras"` | [`Plate::Eurasian`] |
/// | `"afr"` | [`Plate::African`] |
/// | `"pacific"` | [`Plate::Pacific`] |
/// | `"antarcti"` | [`Plate::Antarctic`] |
/// | `"austral"` | [`Plate::Australian`] |
/// | `"south_am"` | [`Plate::SouthAmerican`] |
/// | `"sam"` | [`Plate::SouthAmerican`] |
/// | `"arabian"` or `"arab"` | [`Plate::Arabian`] |
///
/// # Examples
///
/// ```
/// use oxiproj_core::plate_motion::Plate;
/// use oxiproj_transformations::epoch_transform::plate_for_frame;
///
/// assert_eq!(plate_for_frame("NOAM_2014"), Some(Plate::NorthAmerican));
/// assert_eq!(plate_for_frame("EURAS_plate"), Some(Plate::Eurasian));
/// assert_eq!(plate_for_frame("unknown_frame"), None);
/// ```
pub fn plate_for_frame(frame_name: &str) -> Option<Plate> {
    let lower = frame_name.to_ascii_lowercase();

    if lower.contains("noam") || lower.contains("north_am") {
        return Some(Plate::NorthAmerican);
    }
    if lower.contains("euras") {
        return Some(Plate::Eurasian);
    }
    if lower.contains("afr") {
        return Some(Plate::African);
    }
    if lower.contains("pacific") {
        return Some(Plate::Pacific);
    }
    if lower.contains("antarcti") {
        return Some(Plate::Antarctic);
    }
    if lower.contains("austral") {
        return Some(Plate::Australian);
    }
    // "south_am" must be tested before bare "sam" to avoid false positives
    if lower.contains("south_am") {
        return Some(Plate::SouthAmerican);
    }
    if lower.contains("sam") {
        return Some(Plate::SouthAmerican);
    }
    // "arabian" before "arab" — "arabian" already contains "arab", so either
    // order is equivalent here, but listing the longer string first is clearer.
    if lower.contains("arabian") || lower.contains("arab") {
        return Some(Plate::Arabian);
    }

    None
}

/// Multi-step epoch-aware pathfinding:
/// 1. Propagate from `from_epoch` to `to_epoch` in `from_frame` using plate motion
/// 2. Apply ITRF frame transform if frames differ (direct or inverse)
/// 3. Result is in `to_frame` at `to_epoch`
///
/// # Arguments
/// * `from_frame` — Source ITRF frame name (e.g. `"ITRF2020"`)
/// * `to_frame` — Target ITRF frame name (e.g. `"ITRF2014"`)
/// * `from_epoch` — Source epoch
/// * `to_epoch` — Target epoch
/// * `x, y, z` — ECEF coordinates in metres
/// * `model` — Plate motion model
/// * `plate` — Tectonic plate
#[allow(clippy::too_many_arguments)]
pub fn epoch_path(
    from_frame: &str,
    to_frame: &str,
    from_epoch: &Epoch,
    to_epoch: &Epoch,
    x: f64,
    y: f64,
    z: f64,
    model: PlateMotionModel,
    plate: Plate,
) -> ProjResult<(f64, f64, f64)> {
    let (x1, y1, z1) = propagate_epoch(x, y, z, from_epoch, to_epoch, model, plate)?;

    if from_frame.eq_ignore_ascii_case(to_frame) {
        return Ok((x1, y1, z1));
    }

    if let Some(ft) = find_frame_transform(from_frame, to_frame) {
        return apply_frame_transform(ft, x1, y1, z1, to_epoch.year)
            .map_err(|_| ProjError::NoOperation);
    }

    if let Some(ft) = find_frame_transform(to_frame, from_frame) {
        return apply_frame_transform_inverse(ft, x1, y1, z1, to_epoch.year)
            .map_err(|_| ProjError::NoOperation);
    }

    Err(ProjError::NoOperation)
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxiproj_core::epoch::Epoch;
    use oxiproj_core::plate_motion::{Plate, PlateMotionModel};

    #[test]
    fn propagate_eurasian_plate_10_years() {
        let (x, y, z) = (3_900_000.0_f64, 900_000.0_f64, 4_900_000.0_f64);
        let from = Epoch::from_decimal_year(2015.0).unwrap();
        let to = Epoch::from_decimal_year(2025.0).unwrap();
        let (x2, y2, z2) = propagate_epoch(
            x,
            y,
            z,
            &from,
            &to,
            PlateMotionModel::Itrf2014,
            Plate::Eurasian,
        )
        .unwrap();
        let shift = ((x2 - x).powi(2) + (y2 - y).powi(2) + (z2 - z).powi(2)).sqrt();
        assert!(shift > 0.05 && shift < 0.5, "shift={}", shift);
    }

    #[test]
    fn plate_for_frame_lookups() {
        assert_eq!(plate_for_frame("NOAM_2014"), Some(Plate::NorthAmerican));
        assert_eq!(plate_for_frame("EURAS_plate"), Some(Plate::Eurasian));
        assert_eq!(plate_for_frame("unknown_frame"), None);
    }

    #[test]
    fn plate_for_frame_all_patterns() {
        assert_eq!(plate_for_frame("noam_itrf"), Some(Plate::NorthAmerican));
        assert_eq!(
            plate_for_frame("north_am_plate"),
            Some(Plate::NorthAmerican)
        );
        assert_eq!(plate_for_frame("AFR_2014"), Some(Plate::African));
        assert_eq!(plate_for_frame("PACIFIC"), Some(Plate::Pacific));
        assert_eq!(plate_for_frame("ANTARCTI_plate"), Some(Plate::Antarctic));
        assert_eq!(plate_for_frame("AUSTRAL_zone"), Some(Plate::Australian));
        assert_eq!(plate_for_frame("SOUTH_AM_2020"), Some(Plate::SouthAmerican));
        assert_eq!(plate_for_frame("ARABIAN_plate"), Some(Plate::Arabian));
        assert_eq!(plate_for_frame("ARAB_zone"), Some(Plate::Arabian));
        assert_eq!(plate_for_frame("UNKNOWN"), None);
    }

    #[test]
    fn propagate_returns_error_for_unknown_plate_in_model() {
        // NnrMorvel56 has no Arabian plate
        let from = Epoch::from_decimal_year(2015.0).unwrap();
        let to = Epoch::from_decimal_year(2025.0).unwrap();
        let result = propagate_epoch(
            1_000_000.0,
            2_000_000.0,
            3_000_000.0,
            &from,
            &to,
            PlateMotionModel::NnrMorvel56,
            Plate::Arabian,
        );
        assert!(result.is_err());
    }

    #[test]
    fn epoch_path_same_frame_is_propagation_only() {
        // Same frame, 1-year propagation: should match direct propagation
        let from = Epoch::from_decimal_year(2020.0).unwrap();
        let to = Epoch::from_decimal_year(2021.0).unwrap();
        let (x, y, z) = (4_627_798.0_f64, 119_795.0_f64, 4_369_668.0_f64);
        let result = epoch_path(
            "ITRF2020",
            "ITRF2020",
            &from,
            &to,
            x,
            y,
            z,
            PlateMotionModel::Itrf2020,
            Plate::Eurasian,
        );
        assert!(
            result.is_ok(),
            "epoch_path same frame failed: {:?}",
            result.err()
        );
        let (x2, y2, z2) = result.unwrap();
        // Should be close to propagate_epoch result
        let direct = propagate_epoch(
            x,
            y,
            z,
            &from,
            &to,
            PlateMotionModel::Itrf2020,
            Plate::Eurasian,
        )
        .unwrap();
        assert!((x2 - direct.0).abs() < 1e-6, "x mismatch");
        assert!((y2 - direct.1).abs() < 1e-6, "y mismatch");
        assert!((z2 - direct.2).abs() < 1e-6, "z mismatch");
    }

    #[test]
    fn epoch_path_itrf2020_to_itrf2014() {
        let from = Epoch::from_decimal_year(2020.0).unwrap();
        let to = Epoch::from_decimal_year(2020.0).unwrap();
        let (x, y, z) = (4_627_798.0_f64, 119_795.0_f64, 4_369_668.0_f64);
        let result = epoch_path(
            "ITRF2020",
            "ITRF2014",
            &from,
            &to,
            x,
            y,
            z,
            PlateMotionModel::Itrf2020,
            Plate::Eurasian,
        );
        assert!(
            result.is_ok(),
            "epoch_path cross frame failed: {:?}",
            result.err()
        );
        let (xo, yo, zo) = result.unwrap();
        // Frame transform should shift coordinates by mm-level
        let dist = ((xo - x).powi(2) + (yo - y).powi(2) + (zo - z).powi(2)).sqrt();
        assert!(dist < 1.0, "frame shift > 1m: {}", dist); // should be mm-level
        assert!(dist > 0.0, "no shift at all");
    }
}