use crate::time::{Duration, Epoch, TimeWindow};
use crate::types::{Ecef4, Geodetic4, StateVector};
use geo3d::{Ecef, Vec3};
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Waypoint {
pub epoch: Epoch,
pub position: Ecef,
pub velocity: Option<Vec3>,
}
impl Waypoint {
#[inline]
pub const fn new(epoch: Epoch, position: Ecef, velocity: Vec3) -> Self {
Waypoint {
epoch,
position,
velocity: Some(velocity),
}
}
#[inline]
pub const fn position_only(epoch: Epoch, position: Ecef) -> Self {
Waypoint {
epoch,
position,
velocity: None,
}
}
}
impl From<StateVector> for Waypoint {
#[inline]
fn from(s: StateVector) -> Waypoint {
Waypoint::new(s.epoch, s.position, s.velocity)
}
}
impl From<Ecef4> for Waypoint {
#[inline]
fn from(p: Ecef4) -> Waypoint {
Waypoint::position_only(p.epoch, p.position)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Interpolation {
#[default]
Linear,
Hermite,
CatmullRom,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Extrapolation {
#[default]
None,
Clamp,
Linear,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TrajectoryError {
NonMonotonic,
}
impl core::fmt::Display for TrajectoryError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
TrajectoryError::NonMonotonic => f.write_str("waypoint epochs must be strictly increasing"),
}
}
}
impl std::error::Error for TrajectoryError {}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Trajectory {
points: Vec<Waypoint>,
interpolation: Interpolation,
extrapolation: Extrapolation,
}
impl Trajectory {
#[inline]
pub fn new() -> Self {
Trajectory {
points: Vec::new(),
interpolation: Interpolation::Linear,
extrapolation: Extrapolation::None,
}
}
pub fn from_states<I: IntoIterator<Item = StateVector>>(states: I) -> Result<Self, TrajectoryError> {
Self::from_waypoints(states.into_iter().map(Waypoint::from))
}
pub fn from_samples<I: IntoIterator<Item = Ecef4>>(samples: I) -> Result<Self, TrajectoryError> {
Self::from_waypoints(samples.into_iter().map(Waypoint::from))
}
pub fn from_waypoints<I: IntoIterator<Item = Waypoint>>(waypoints: I) -> Result<Self, TrajectoryError> {
let mut points: Vec<Waypoint> = waypoints.into_iter().collect();
points.sort_by_key(|w| w.epoch);
if points.windows(2).any(|w| w[0].epoch == w[1].epoch) {
return Err(TrajectoryError::NonMonotonic);
}
Ok(Trajectory {
points,
interpolation: Interpolation::Linear,
extrapolation: Extrapolation::None,
})
}
#[inline]
pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self {
self.interpolation = interpolation;
self
}
#[inline]
pub fn with_extrapolation(mut self, extrapolation: Extrapolation) -> Self {
self.extrapolation = extrapolation;
self
}
pub fn push(&mut self, waypoint: Waypoint) -> Result<(), TrajectoryError> {
if self.points.last().is_some_and(|last| waypoint.epoch <= last.epoch) {
return Err(TrajectoryError::NonMonotonic);
}
self.points.push(waypoint);
Ok(())
}
#[inline]
pub fn waypoints(&self) -> &[Waypoint] {
&self.points
}
#[inline]
pub fn len(&self) -> usize {
self.points.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
#[inline]
pub fn start(&self) -> Option<Epoch> {
self.points.first().map(|w| w.epoch)
}
#[inline]
pub fn end(&self) -> Option<Epoch> {
self.points.last().map(|w| w.epoch)
}
#[inline]
pub fn window(&self) -> TimeWindow {
TimeWindow::new(self.start(), self.end())
}
#[inline]
pub fn duration(&self) -> Option<Duration> {
match (self.start(), self.end()) {
(Some(s), Some(e)) if s != e => Some(e - s),
_ => None,
}
}
pub fn position_at(&self, epoch: Epoch) -> Option<Ecef> {
self.evaluate(epoch).map(|(p, _)| p)
}
#[inline]
pub fn sample(&self, epoch: Epoch) -> Option<Ecef4> {
self.position_at(epoch).map(|p| Ecef4::new(p, epoch))
}
pub fn state_at(&self, epoch: Epoch) -> Option<StateVector> {
self.evaluate(epoch).map(|(p, v)| StateVector::new(epoch, p, v))
}
#[inline]
pub fn geodetic_at(&self, epoch: Epoch) -> Option<Geodetic4> {
self.sample(epoch).map(Ecef4::to_geodetic4)
}
pub fn resample(&self, step: Duration) -> Vec<Ecef4> {
let (Some(start), Some(end)) = (self.start(), self.end()) else {
return Vec::new();
};
let mut out = Vec::new();
let mut t = start;
while t <= end {
if let Some(p) = self.sample(t) {
out.push(p);
}
t = t + step;
}
out
}
fn evaluate(&self, epoch: Epoch) -> Option<(Ecef, Vec3)> {
let n = self.points.len();
if n == 0 {
return None;
}
let (start, end) = (self.points[0].epoch, self.points[n - 1].epoch);
if epoch < start {
return self.off_end(0, epoch);
}
if epoch > end {
return self.off_end(n - 1, epoch);
}
if n == 1 {
return Some((self.points[0].position, self.tangent(0)));
}
let hi = self.points.partition_point(|w| w.epoch <= epoch);
let i = hi.saturating_sub(1).min(n - 2);
Some(self.eval_segment(i, epoch))
}
fn off_end(&self, idx: usize, epoch: Epoch) -> Option<(Ecef, Vec3)> {
let w = self.points[idx];
match self.extrapolation {
Extrapolation::None => None,
Extrapolation::Clamp => Some((w.position, Vec3::ZERO)),
Extrapolation::Linear => {
let v = self.tangent(idx);
let dt = (epoch - w.epoch).as_seconds();
Some((w.position.offset(v * dt), v))
}
}
}
fn eval_segment(&self, i: usize, epoch: Epoch) -> (Ecef, Vec3) {
let (w0, w1) = (self.points[i], self.points[i + 1]);
let dt = (w1.epoch - w0.epoch).as_seconds();
let u = (epoch - w0.epoch).as_seconds() / dt;
match self.interpolation {
Interpolation::Linear => {
let secant = (w1.position.vec() - w0.position.vec()) / dt;
(Ecef::from(w0.position.vec() + secant * (u * dt)), secant)
}
Interpolation::Hermite | Interpolation::CatmullRom => {
let (p0, p1) = (w0.position.vec(), w1.position.vec());
let (m0, m1) = (self.tangent(i), self.tangent(i + 1));
hermite(p0, m0, p1, m1, dt, u)
}
}
}
fn tangent(&self, i: usize) -> Vec3 {
match self.interpolation {
Interpolation::Linear => self.secant_tangent(i),
Interpolation::Hermite => self.points[i].velocity.unwrap_or_else(|| self.secant_tangent(i)),
Interpolation::CatmullRom => self.catmull_tangent(i),
}
}
fn secant_tangent(&self, i: usize) -> Vec3 {
let n = self.points.len();
if n < 2 {
return Vec3::ZERO;
}
let (a, b) = (i.saturating_sub(1), (i + 1).min(n - 1));
let (pa, pb) = (self.points[a].position.vec(), self.points[b].position.vec());
let span = (self.points[b].epoch - self.points[a].epoch).as_seconds();
if span == 0.0 {
Vec3::ZERO
} else {
(pb - pa) / span
}
}
fn catmull_tangent(&self, i: usize) -> Vec3 {
let n = self.points.len();
if n < 2 {
return Vec3::ZERO;
}
if i == 0 || i == n - 1 {
return self.secant_tangent(i);
}
let (pm, p0, pp) = (
self.points[i - 1].position.vec(),
self.points[i].position.vec(),
self.points[i + 1].position.vec(),
);
let (tm, t0, tp) = (self.points[i - 1].epoch, self.points[i].epoch, self.points[i + 1].epoch);
let d_prev = (t0 - tm).as_seconds();
let d_next = (tp - t0).as_seconds();
let d_span = (tp - tm).as_seconds();
(p0 - pm) / d_prev - (pp - pm) / d_span + (pp - p0) / d_next
}
}
impl Default for Trajectory {
#[inline]
fn default() -> Self {
Trajectory::new()
}
}
fn hermite(p0: Vec3, m0: Vec3, p1: Vec3, m1: Vec3, dt: f64, u: f64) -> (Ecef, Vec3) {
let u2 = u * u;
let u3 = u2 * u;
let (h00, h10, h01, h11) = (
2.0 * u3 - 3.0 * u2 + 1.0,
u3 - 2.0 * u2 + u,
-2.0 * u3 + 3.0 * u2,
u3 - u2,
);
let pos = p0 * h00 + m0 * (h10 * dt) + p1 * h01 + m1 * (h11 * dt);
let (d00, d10, d01, d11) = (
6.0 * u2 - 6.0 * u,
3.0 * u2 - 4.0 * u + 1.0,
-6.0 * u2 + 6.0 * u,
3.0 * u2 - 2.0 * u,
);
let vel = (p0 * d00 + m0 * (d10 * dt) + p1 * d01 + m1 * (d11 * dt)) / dt;
(Ecef::from(pos), vel)
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn t0() -> Epoch {
Epoch::from_unix_seconds(1_700_000_000.0)
}
#[test]
fn linear_interpolation_is_the_chord() {
let a = Ecef4::new(Ecef::new(0.0, 0.0, 0.0), t0());
let b = Ecef4::new(Ecef::new(100.0, 0.0, 0.0), t0() + Duration::from_seconds(10.0));
let traj = Trajectory::from_samples([a, b]).unwrap();
let mid = traj.position_at(t0() + Duration::from_seconds(5.0)).unwrap();
assert_eq!(mid, Ecef::new(50.0, 0.0, 0.0));
assert_eq!(traj.position_at(t0()).unwrap(), a.position);
assert_eq!(traj.position_at(b.epoch).unwrap(), b.position);
assert!(close(
traj.state_at(t0() + Duration::from_seconds(2.0)).unwrap().velocity.x,
10.0,
1e-9
));
}
#[test]
fn hermite_passes_through_nodes_with_given_velocity() {
let states = [
StateVector::new(t0(), Ecef::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0)),
StateVector::new(
t0() + Duration::from_seconds(10.0),
Ecef::new(100.0, 50.0, 0.0),
Vec3::new(10.0, 0.0, 0.0),
),
];
let traj = Trajectory::from_states(states)
.unwrap()
.with_interpolation(Interpolation::Hermite);
for s in states {
let got = traj.state_at(s.epoch).unwrap();
assert!(got.position.distance_to(s.position) < 1e-6);
assert!((got.velocity - s.velocity).norm() < 1e-6);
}
let mid = traj.position_at(t0() + Duration::from_seconds(5.0)).unwrap();
assert!(mid.x > 0.0 && mid.x < 100.0 && mid.y > 0.0 && mid.y <= 50.0);
}
#[test]
fn extrapolation_policies() {
let a = Ecef4::new(Ecef::new(0.0, 0.0, 0.0), t0());
let b = Ecef4::new(Ecef::new(100.0, 0.0, 0.0), t0() + Duration::from_seconds(10.0));
let before = t0() - Duration::from_seconds(5.0);
let after = b.epoch + Duration::from_seconds(5.0);
let none = Trajectory::from_samples([a, b]).unwrap();
assert!(none.position_at(before).is_none() && none.position_at(after).is_none());
let clamp = none.clone().with_extrapolation(Extrapolation::Clamp);
assert_eq!(clamp.position_at(before).unwrap(), a.position);
assert_eq!(clamp.position_at(after).unwrap(), b.position);
let lin = none.with_extrapolation(Extrapolation::Linear);
assert!(close(lin.position_at(after).unwrap().x, 150.0, 1e-6));
assert!(close(lin.position_at(before).unwrap().x, -50.0, 1e-6));
}
#[test]
fn catmull_rom_is_smooth_through_interior_points() {
let pts: Vec<Ecef4> = (0..5)
.map(|k| {
Ecef4::new(
Ecef::new(k as f64 * 100.0, (k as f64 * 100.0).sin() * 50.0, 0.0),
t0() + Duration::from_seconds(k as f64 * 10.0),
)
})
.collect();
let traj = Trajectory::from_samples(pts.clone())
.unwrap()
.with_interpolation(Interpolation::CatmullRom);
for p in &pts {
let got = traj.position_at(p.epoch).unwrap();
assert!(got.distance_to(p.position) < 1e-6, "{got:?} vs {:?}", p.position);
}
}
#[test]
fn build_validation_and_resample() {
let a = Ecef4::new(Ecef::new(0.0, 0.0, 0.0), t0());
assert_eq!(
Trajectory::from_samples([a, a]).err(),
Some(TrajectoryError::NonMonotonic)
);
let mut tr = Trajectory::new();
assert!(tr.push(Waypoint::from(a)).is_ok());
assert_eq!(
tr.push(Waypoint::position_only(
t0() - Duration::SECOND,
Ecef::new(1.0, 0.0, 0.0)
))
.err(),
Some(TrajectoryError::NonMonotonic)
);
let b = Ecef4::new(Ecef::new(100.0, 0.0, 0.0), t0() + Duration::from_seconds(10.0));
let traj = Trajectory::from_samples([b, a]).unwrap(); assert_eq!(traj.start(), Some(t0()));
assert_eq!(traj.duration(), Some(Duration::from_seconds(10.0)));
let s = traj.resample(Duration::from_seconds(2.0));
assert_eq!(s.len(), 6);
assert_eq!(s[0].position, a.position);
assert!(close(s[3].position.x, 60.0, 1e-9));
}
}