use crate::epoch_transform::{plate_for_coord, plate_for_frame, propagate_epoch};
use crate::frame_chain::{apply_frame_path, find_frame_path};
use oxiproj_core::{
epoch::{Epoch, RefFrameEpoch},
plate_motion::{Plate, PlateMotionModel},
ProjError, ProjResult,
};
const EPOCH_EPS_YEARS: f64 = 1e-9;
#[derive(Debug, Clone)]
pub struct EpochAwareResult {
pub x: f64,
pub y: f64,
pub z: f64,
pub from_frame: String,
pub from_epoch: Epoch,
pub to_frame: String,
pub to_epoch: Epoch,
}
fn resolve_plate(frame_name: &str, x: f64, y: f64, z: f64) -> Option<Plate> {
if let Some(p) = plate_for_frame(frame_name) {
return Some(p);
}
plate_for_coord(x, y, z)
}
#[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 (px, py, pz) = if (to_epoch.year - from_epoch.year).abs() > EPOCH_EPS_YEARS {
let plate = resolve_plate(from_frame, x, y, z).ok_or(ProjError::NoOperation)?;
propagate_epoch(x, y, z, &from_epoch, &to_epoch, model, plate)?
} else {
(x, y, z)
};
let path = find_frame_path(from_frame, to_frame).ok_or(ProjError::NoOperation)?;
let (final_x, final_y, final_z) =
apply_frame_path(&path, px, py, pz, to_epoch.year).map_err(|_| ProjError::NoOperation)?;
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,
})
}
pub fn transform_epoch_aware_frames(
from: &RefFrameEpoch,
to: &RefFrameEpoch,
x: f64,
y: f64,
z: f64,
model: PlateMotionModel,
) -> ProjResult<EpochAwareResult> {
let from_epoch = from.epoch.ok_or(ProjError::IllegalArgValue)?;
let to_epoch = to.epoch.ok_or(ProjError::IllegalArgValue)?;
transform_epoch_aware(x, y, z, &from.frame, from_epoch, &to.frame, to_epoch, model)
}
#[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() {
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);
}
#[test]
fn test_same_epoch_frame_change_matches_cct() {
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(2015.0).unwrap(),
PlateMotionModel::Itrf2020,
)
.expect("same-epoch frame change should succeed anywhere");
assert!(
(result.x - 4_627_797.996_656).abs() < 5e-6,
"x={}",
result.x
);
assert!((result.y - 119_794.999_050).abs() < 5e-6, "y={}", result.y);
assert!(
(result.z - 4_369_667.999_565).abs() < 5e-6,
"z={}",
result.z
);
}
#[test]
fn test_multi_hop_itrf2014_to_itrf2000_matches_cct() {
let result = transform_epoch_aware(
4_627_798.0,
119_795.0,
4_369_668.0,
"ITRF2014",
Epoch::new(2015.0).unwrap(),
"ITRF2000",
Epoch::new(2015.0).unwrap(),
PlateMotionModel::Itrf2020,
)
.expect("multi-hop should succeed");
assert!(
(result.x - 4_627_798.013_556).abs() < 1e-5,
"x={}",
result.x
);
assert!((result.y - 119_795.002_020).abs() < 1e-5, "y={}", result.y);
assert!(
(result.z - 4_369_667.976_067).abs() < 1e-5,
"z={}",
result.z
);
}
#[test]
fn test_north_american_station_uses_north_american_plate() {
let (x0, y0, z0) = (-1_274_000.0, -4_733_000.0, 4_074_000.0);
assert_eq!(
resolve_plate("ITRF2014", x0, y0, z0),
Some(Plate::NorthAmerican)
);
let via_pipeline = transform_epoch_aware(
x0,
y0,
z0,
"ITRF2014",
Epoch::new(2010.0).unwrap(),
"ITRF2014",
Epoch::new(2020.0).unwrap(),
PlateMotionModel::Itrf2014,
)
.expect("should succeed");
let expected = PlateMotionModel::Itrf2014
.propagate(Plate::NorthAmerican, x0, y0, z0, 2010.0, 2020.0)
.expect("propagate");
assert!((via_pipeline.x - expected.0).abs() < 1e-9);
assert!((via_pipeline.y - expected.1).abs() < 1e-9);
assert!((via_pipeline.z - expected.2).abs() < 1e-9);
let eurasian = PlateMotionModel::Itrf2014
.propagate(Plate::Eurasian, x0, y0, z0, 2010.0, 2020.0)
.expect("propagate");
let diff = ((expected.0 - eurasian.0).powi(2)
+ (expected.1 - eurasian.1).powi(2)
+ (expected.2 - eurasian.2).powi(2))
.sqrt();
assert!(
diff > 1e-3,
"N. American vs Eurasian velocity should differ by >1mm over a decade: {diff}m"
);
}
#[test]
fn test_frames_convenience_entry_point() {
let from: RefFrameEpoch = "ITRF2020@2015".parse().unwrap();
let to: RefFrameEpoch = "ITRF2014@2015".parse().unwrap();
let result = transform_epoch_aware_frames(
&from,
&to,
4_627_798.0,
119_795.0,
4_369_668.0,
PlateMotionModel::Itrf2020,
)
.expect("frames entry point should succeed");
assert!(
(result.x - 4_627_797.996_656).abs() < 5e-6,
"x={}",
result.x
);
assert_eq!(result.from_frame, "ITRF2020");
assert_eq!(result.to_frame, "ITRF2014");
}
#[test]
fn test_frames_convenience_requires_epochs() {
let from = RefFrameEpoch::static_frame("ITRF2020");
let to: RefFrameEpoch = "ITRF2014@2015".parse().unwrap();
let result = transform_epoch_aware_frames(
&from,
&to,
4_627_798.0,
119_795.0,
4_369_668.0,
PlateMotionModel::Itrf2020,
);
assert!(result.is_err(), "static source frame should be rejected");
}
}