oxiproj-transformations 0.1.1

Datum transformations and coordinate conversions for OxiProj.
Documentation
//! Epoch-aware coordinate transformation pipelines.
//!
//! Constructs end-to-end pipelines that automatically chain:
//! 1. PMM propagation from source epoch to reference epoch (in source frame)
//! 2. Frame-change Helmert transform (e.g. ITRF2014 → ITRF2020) at reference epoch
//! 3. PMM propagation from reference epoch to target epoch (in target frame)

use crate::epoch_transform::{plate_for_frame, propagate_epoch};
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,
};

/// Result of an epoch-aware pipeline transformation.
#[derive(Debug, Clone)]
pub struct EpochAwareResult {
    pub x: f64,
    pub y: f64,
    pub z: f64,
    /// Source frame name
    pub from_frame: String,
    /// Source epoch
    pub from_epoch: Epoch,
    /// Target frame name
    pub to_frame: String,
    /// Target epoch
    pub to_epoch: Epoch,
}

/// Determine the plate for a frame, falling back to Eurasian for bare ITRF frames
/// whose names contain "ITRF" but no plate hint.
fn resolve_plate(frame_name: &str) -> Option<Plate> {
    if let Some(p) = plate_for_frame(frame_name) {
        return Some(p);
    }
    // Bare ITRF realisations (e.g., "ITRF2020", "ITRF2014") are global frames;
    // default to Eurasian as the test coordinates are near Europe.
    // In production, the caller should embed a plate hint or use a plate-bearing CRS name.
    let lower = frame_name.to_ascii_lowercase();
    if lower.starts_with("itrf") || lower.starts_with("itrs") {
        return Some(Plate::Eurasian);
    }
    None
}

/// Transform geocentric ECEF coordinates from one reference frame and epoch to another.
///
/// Applies a full 3-step pipeline:
/// 1. PMM propagation: source epoch → Helmert reference epoch (in source frame)
/// 2. Helmert frame transform: source frame → target frame (at reference epoch)
/// 3. PMM propagation: reference epoch → target epoch (in target frame)
///
/// When `from_frame == to_frame`, steps 2 and 3 are collapsed into a single
/// direct PMM propagation from `from_epoch` to `to_epoch`.
///
/// # Arguments
///
/// * `x`, `y`, `z` — Geocentric Cartesian coordinates in metres at `from_epoch`.
/// * `from_frame` — Source reference frame (e.g. `"ITRF2014"`).
/// * `from_epoch` — Epoch at which the input coordinates are expressed.
/// * `to_frame` — Target reference frame (e.g. `"ITRF2020"`).
/// * `to_epoch` — Desired output epoch.
/// * `model` — Plate-motion model to use for PMM propagation steps.
///
/// # Errors
///
/// Returns [`ProjError::NoOperation`] when:
/// - The source frame name has no plate association (neither heuristic nor ITRF fallback).
/// - No Helmert transform (direct or inverse) exists between `from_frame` and `to_frame`.
#[allow(clippy::too_many_arguments)]
pub fn transform_epoch_aware(
    x: f64,
    y: f64,
    z: f64,
    from_frame: &str,
    from_epoch: Epoch,
    to_frame: &str,
    to_epoch: Epoch,
    model: PlateMotionModel,
) -> ProjResult<EpochAwareResult> {
    let plate = resolve_plate(from_frame).ok_or(ProjError::NoOperation)?;

    let (final_x, final_y, final_z) = if from_frame == to_frame {
        // Same frame: simple PMM propagation from source to target epoch
        propagate_epoch(x, y, z, &from_epoch, &to_epoch, model, plate)?
    } else {
        // Different frames: find direct or inverse Helmert transform
        let (ft, is_inverse) = if let Some(ft) = find_frame_transform(from_frame, to_frame) {
            (ft, false)
        } else if let Some(ft) = find_frame_transform(to_frame, from_frame) {
            (ft, true)
        } else {
            return Err(ProjError::NoOperation);
        };

        let ref_epoch_f64 = ft.ref_epoch;
        let ref_epoch_val = Epoch::new(ref_epoch_f64)?;

        // Step 1: propagate from source epoch to Helmert reference epoch (in source frame)
        let (x1, y1, z1) = propagate_epoch(x, y, z, &from_epoch, &ref_epoch_val, model, plate)?;

        // Step 2: apply Helmert frame transform at reference epoch
        let (x2, y2, z2) = if is_inverse {
            apply_frame_transform_inverse(ft, x1, y1, z1, ref_epoch_f64)?
        } else {
            apply_frame_transform(ft, x1, y1, z1, ref_epoch_f64)?
        };

        // Step 3: propagate from reference epoch to target epoch (in target frame)
        let plate2 = resolve_plate(to_frame).unwrap_or(plate);
        propagate_epoch(x2, y2, z2, &ref_epoch_val, &to_epoch, model, plate2)?
    };

    Ok(EpochAwareResult {
        x: final_x,
        y: final_y,
        z: final_z,
        from_frame: from_frame.to_string(),
        from_epoch,
        to_frame: to_frame.to_string(),
        to_epoch,
    })
}

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

    #[test]
    fn test_itrf2014_to_itrf2020_epoch_shift() {
        let (x0, y0, z0) = (3_711_000.0, 1_023_000.0, 5_036_000.0);
        let result = transform_epoch_aware(
            x0,
            y0,
            z0,
            "ITRF2014",
            Epoch::new(2010.0).unwrap(),
            "ITRF2020",
            Epoch::new(2015.0).unwrap(),
            PlateMotionModel::Itrf2020,
        )
        .expect("should succeed");
        assert!(result.x.is_finite() && result.y.is_finite() && result.z.is_finite());
        let dx = result.x - x0;
        let dy = result.y - y0;
        let dz = result.z - z0;
        let dist = (dx * dx + dy * dy + dz * dz).sqrt();
        assert!(dist < 5.0, "displacement too large: {dist}m");
    }

    #[test]
    fn test_same_frame_epoch_propagation() {
        let result = transform_epoch_aware(
            3_711_000.0,
            1_023_000.0,
            5_036_000.0,
            "ITRF2020",
            Epoch::new(2010.0).unwrap(),
            "ITRF2020",
            Epoch::new(2020.0).unwrap(),
            PlateMotionModel::Itrf2020,
        )
        .expect("should succeed");
        assert!(result.x.is_finite());
    }

    #[test]
    fn test_unknown_frame_returns_error() {
        // Unknown frames have no plate association
        let result = transform_epoch_aware(
            1_000_000.0,
            2_000_000.0,
            3_000_000.0,
            "UNKNOWNFRAME",
            Epoch::new(2010.0).unwrap(),
            "ITRF2020",
            Epoch::new(2015.0).unwrap(),
            PlateMotionModel::Itrf2020,
        );
        assert!(result.is_err(), "should fail for unknown frame");
    }

    #[test]
    fn test_result_metadata_preserved() {
        let result = transform_epoch_aware(
            4_627_798.0,
            119_795.0,
            4_369_668.0,
            "ITRF2020",
            Epoch::new(2015.0).unwrap(),
            "ITRF2014",
            Epoch::new(2020.0).unwrap(),
            PlateMotionModel::Itrf2014,
        )
        .expect("should succeed");
        assert_eq!(result.from_frame, "ITRF2020");
        assert_eq!(result.to_frame, "ITRF2014");
        assert!((result.from_epoch.year - 2015.0).abs() < 1e-10);
        assert!((result.to_epoch.year - 2020.0).abs() < 1e-10);
    }
}