use crate::frametransform;
use crate::orbitprop;
use crate::orbitprop::PropSettings;
use crate::orbitprop::SatProperties;
use crate::{Frame, Instant};
use crate::TimeLike;
use crate::mathtypes::*;
use anyhow::Result;
type PVCovType = Matrix<6, 6>;
#[derive(Clone, Debug)]
#[allow(clippy::large_enum_variant)]
pub enum StateCov {
None,
PVCov(PVCovType),
}
#[derive(Clone, Debug)]
pub struct ImpulsiveManeuver {
pub time: Instant,
pub delta_v: Vector3,
pub frame: Frame,
}
impl ImpulsiveManeuver {
pub fn new(time: Instant, delta_v: Vector3, frame: Frame) -> Self {
Self {
time,
delta_v,
frame,
}
}
pub fn prograde(time: Instant, dv_mps: f64) -> Self {
Self::new(time, numeris::vector![0.0, dv_mps, 0.0], Frame::NTW)
}
pub fn retrograde(time: Instant, dv_mps: f64) -> Self {
Self::new(time, numeris::vector![0.0, -dv_mps, 0.0], Frame::NTW)
}
pub fn radial_out(time: Instant, dv_mps: f64) -> Self {
Self::new(time, numeris::vector![dv_mps, 0.0, 0.0], Frame::NTW)
}
pub fn normal(time: Instant, dv_mps: f64) -> Self {
Self::new(time, numeris::vector![0.0, 0.0, dv_mps], Frame::NTW)
}
pub fn gcrf(time: Instant, delta_v: Vector3) -> Self {
Self::new(time, delta_v, Frame::GCRF)
}
pub fn ric(time: Instant, delta_v: Vector3) -> Self {
Self::new(time, delta_v, Frame::RTN)
}
pub fn ntw(time: Instant, delta_v: Vector3) -> Self {
Self::new(time, delta_v, Frame::NTW)
}
fn delta_v_gcrf(&self, pos_gcrf: &Vector3, vel_gcrf: &Vector3) -> Vector3 {
match self.frame {
Frame::GCRF => self.delta_v,
Frame::RTN => {
let dcm = frametransform::rtn_to_gcrf(pos_gcrf, vel_gcrf);
dcm * self.delta_v
}
Frame::NTW => {
let dcm = frametransform::ntw_to_gcrf(pos_gcrf, vel_gcrf);
dcm * self.delta_v
}
Frame::LVLH => {
let dcm = frametransform::lvlh_to_gcrf(pos_gcrf, vel_gcrf);
dcm * self.delta_v
}
Frame::ITRF
| Frame::TIRS
| Frame::CIRS
| Frame::TEME
| Frame::EME2000
| Frame::ICRF => panic!(
"Unsupported frame for maneuver: {}. Must be GCRF, RIC, NTW, or LVLH",
self.frame
),
}
}
}
#[derive(Clone, Debug)]
pub struct SatState {
pub time: Instant,
pub pv: Vector6,
pub cov: StateCov,
pub maneuvers: Vec<ImpulsiveManeuver>,
}
impl SatState {
pub fn from_pv<T: TimeLike>(time: &T, pos: &Vector3, vel: &Vector3) -> Self {
Self {
time: time.as_instant(),
pv: numeris::vector![pos[0], pos[1], pos[2], vel[0], vel[1], vel[2]],
cov: StateCov::None,
maneuvers: Vec::new(),
}
}
pub fn pos_gcrf(&self) -> Vector3 {
self.pv.block::<3, 1>(0, 0)
}
pub fn vel_gcrf(&self) -> Vector3 {
self.pv.block::<3, 1>(3, 0)
}
pub fn add_maneuver(&mut self, maneuver: ImpulsiveManeuver) {
self.maneuvers.push(maneuver);
}
pub fn set_cov(&mut self, cov: StateCov) {
self.cov = cov;
}
pub fn qgcrf2lvlh(&self) -> Quaternion {
let p = self.pos_gcrf();
let v = self.vel_gcrf();
let h = p.cross(&v);
let neg_p = p * -1.0;
let z_target = numeris::vector![0.0, 0.0, 1.0];
let q1 = Quaternion::rotation_between(neg_p, z_target);
let rotated_h = q1 * (h * -1.0);
let y_axis = numeris::vector![0.0, 1.0, 0.0];
let q2 = Quaternion::rotation_between(rotated_h, y_axis);
q2 * q1
}
pub fn cov(&self) -> StateCov {
self.cov.clone()
}
fn cov_frame_to_gcrf(&self, frame: Frame) -> Result<Option<Matrix3>> {
let pos = self.pos_gcrf();
let vel = self.vel_gcrf();
match frame {
Frame::GCRF => Ok(None),
Frame::LVLH => Ok(Some(frametransform::lvlh_to_gcrf(&pos, &vel))),
Frame::RTN => Ok(Some(frametransform::rtn_to_gcrf(&pos, &vel))),
Frame::NTW => Ok(Some(frametransform::ntw_to_gcrf(&pos, &vel))),
Frame::ITRF
| Frame::TIRS
| Frame::CIRS
| Frame::TEME
| Frame::EME2000
| Frame::ICRF => anyhow::bail!(
"Unsupported frame for uncertainty: {}. Must be GCRF, LVLH, RIC, or NTW",
frame
),
}
}
pub fn set_pos_uncertainty(&mut self, sigma: &Vector3, frame: Frame) -> Result<()> {
let dcm_opt = self.cov_frame_to_gcrf(frame)?;
let mut pcov_gcrf = Matrix3::zeros();
pcov_gcrf[(0, 0)] = sigma[0] * sigma[0];
pcov_gcrf[(1, 1)] = sigma[1] * sigma[1];
pcov_gcrf[(2, 2)] = sigma[2] * sigma[2];
if let Some(dcm) = dcm_opt {
pcov_gcrf = dcm * pcov_gcrf * dcm.transpose();
}
let prev_vcov = match &self.cov {
StateCov::PVCov(m) => m.block::<3, 3>(3, 3),
StateCov::None => Matrix3::zeros(),
};
let mut m = PVCovType::zeros();
m.set_block(0, 0, &pcov_gcrf);
m.set_block(3, 3, &prev_vcov);
self.cov = StateCov::PVCov(m);
Ok(())
}
pub fn set_vel_uncertainty(&mut self, sigma: &Vector3, frame: Frame) -> Result<()> {
let dcm_opt = self.cov_frame_to_gcrf(frame)?;
let mut vcov_gcrf = Matrix3::zeros();
vcov_gcrf[(0, 0)] = sigma[0] * sigma[0];
vcov_gcrf[(1, 1)] = sigma[1] * sigma[1];
vcov_gcrf[(2, 2)] = sigma[2] * sigma[2];
if let Some(dcm) = dcm_opt {
vcov_gcrf = dcm * vcov_gcrf * dcm.transpose();
}
let prev_pcov = match &self.cov {
StateCov::PVCov(m) => m.block::<3, 3>(0, 0),
StateCov::None => Matrix3::zeros(),
};
let mut m = PVCovType::zeros();
m.set_block(0, 0, &prev_pcov);
m.set_block(3, 3, &vcov_gcrf);
self.cov = StateCov::PVCov(m);
Ok(())
}
fn propagate_segment(
pv: &Vector6,
cov: &StateCov,
from: &Instant,
to: &Instant,
settings: &PropSettings,
satprops: Option<&dyn SatProperties>,
) -> Result<(Vector6, StateCov)> {
if from == to {
return Ok((*pv, cov.clone()));
}
match cov {
StateCov::None => {
let res = orbitprop::propagate(pv, from, to, settings, satprops)?;
Ok((res.state_end, StateCov::None))
}
StateCov::PVCov(cov_mat) => {
let mut state = Matrix::<6, 7>::zeros();
state.set_block(0, 0, pv);
state.set_block(0, 1, &Matrix6::eye());
let res = orbitprop::propagate(&state, from, to, settings, satprops)?;
let new_pv = res.state_end.block::<6, 1>(0, 0);
let phi = res.state_end.block::<6, 6>(0, 1);
let new_cov = StateCov::PVCov(phi * *cov_mat * phi.transpose());
Ok((new_pv, new_cov))
}
}
}
fn apply_maneuver(pv: &mut Vector6, maneuver: &ImpulsiveManeuver, sign: f64) {
let pos = pv.block::<3, 1>(0, 0);
let vel = pv.block::<3, 1>(3, 0);
let dv = maneuver.delta_v_gcrf(&pos, &vel) * sign;
pv[3] += dv[0];
pv[4] += dv[1];
pv[5] += dv[2];
}
pub fn propagate(
&self,
time: &impl TimeLike,
option_settings: Option<&PropSettings>,
satprops: Option<&dyn SatProperties>,
) -> Result<Self> {
let target = time.as_instant();
if target == self.time {
return Ok(self.clone());
}
let default = orbitprop::PropSettings::default();
let settings = option_settings.unwrap_or(&default);
let forward = target > self.time;
let sign = if forward { 1.0 } else { -1.0 };
let (t_min, t_max) = if forward {
(self.time, target)
} else {
(target, self.time)
};
let mut active_maneuvers: Vec<&ImpulsiveManeuver> = self
.maneuvers
.iter()
.filter(|m| m.time >= t_min && m.time < t_max)
.collect();
if forward {
active_maneuvers.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap());
} else {
active_maneuvers.sort_by(|a, b| b.time.partial_cmp(&a.time).unwrap());
}
let mut current_pv = self.pv;
let mut current_cov = self.cov.clone();
let mut current_time = self.time;
for maneuver in &active_maneuvers {
let (new_pv, new_cov) = Self::propagate_segment(
¤t_pv,
¤t_cov,
¤t_time,
&maneuver.time,
settings,
satprops,
)?;
current_pv = new_pv;
current_cov = new_cov;
current_time = maneuver.time;
Self::apply_maneuver(&mut current_pv, maneuver, sign);
}
let (final_pv, final_cov) = Self::propagate_segment(
¤t_pv,
¤t_cov,
¤t_time,
&target,
settings,
satprops,
)?;
Ok(Self {
time: target,
pv: final_pv,
cov: final_cov,
maneuvers: self.maneuvers.clone(),
})
}
}
impl std::fmt::Display for SatState {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let mut s1 = format!(
r#"Satellite State
Time: {}
GCRF Position: [{:+8.0}, {:+8.0}, {:+8.0}] m,
GCRF Velocity: [{:+8.3}, {:+8.3}, {:+8.3}] m/s"#,
self.time, self.pv[0], self.pv[1], self.pv[2], self.pv[3], self.pv[4], self.pv[5],
);
match self.cov {
StateCov::None => {}
StateCov::PVCov(ref cov) => {
s1.push_str(
format!(
r#"
Covariance: {cov:?}"#
)
.as_str(),
);
}
}
if !self.maneuvers.is_empty() {
s1.push_str(&format!(
"\n Maneuvers: {}",
self.maneuvers.len()
));
}
write!(f, "{}", s1)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::consts;
use crate::Duration;
#[test]
fn test_qgcrf2lvlh() -> Result<()> {
let satstate = SatState::from_pv(
&Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap(),
&numeris::vector![consts::GEO_R, 0.0, 0.0],
&numeris::vector![0.0, (consts::MU_EARTH / consts::GEO_R).sqrt(), 0.0],
);
let state2 =
satstate.propagate(&(satstate.time + Duration::from_hours(3.56)), None, None)?;
let rz = (state2.qgcrf2lvlh() * state2.pos_gcrf()) * (-1.0 / state2.pos_gcrf().norm());
let h = state2.pos_gcrf().cross(&state2.vel_gcrf());
let ry = (state2.qgcrf2lvlh() * h) * (-1.0 / h.norm());
let rx = (state2.qgcrf2lvlh() * state2.vel_gcrf()) * (1.0 / state2.vel_gcrf().norm());
let z_axis = numeris::vector![0.0, 0.0, 1.0];
let y_axis = numeris::vector![0.0, 1.0, 0.0];
let x_axis = numeris::vector![1.0, 0.0, 0.0];
assert!((rz - z_axis).norm() < 1.0e-6);
assert!((ry - y_axis).norm() < 1.0e-6);
assert!((rx - x_axis).norm() < 1.0e-4);
Ok(())
}
#[test]
fn test_satstate() -> Result<()> {
let mut satstate = SatState::from_pv(
&Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap(),
&numeris::vector![consts::GEO_R, 0.0, 0.0],
&numeris::vector![0.0, (consts::MU_EARTH / consts::GEO_R).sqrt(), 0.0],
);
satstate.set_pos_uncertainty(&numeris::vector![1.0, 1.0, 1.0], Frame::LVLH)?;
satstate.set_vel_uncertainty(&numeris::vector![0.01, 0.02, 0.03], Frame::LVLH)?;
let state2 =
satstate.propagate(&(satstate.time + Duration::from_days(0.5)), None, None)?;
let state0 = state2.propagate(&satstate.time, None, None)?;
assert!((satstate.pos_gcrf() - state0.pos_gcrf()).norm() < 0.1);
assert!((satstate.vel_gcrf() - state0.vel_gcrf()).norm() < 0.001);
let cov1 = match satstate.cov() {
StateCov::PVCov(v) => v,
StateCov::None => anyhow::bail!("cov is not none"),
};
let cov2 = match state0.cov() {
StateCov::PVCov(v) => v,
StateCov::None => anyhow::bail!("cov is not none"),
};
assert!((cov1 - cov2).norm_inf() < 0.001);
Ok(())
}
#[test]
fn test_satcov() -> Result<()> {
let mut satstate = SatState::from_pv(
&Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap(),
&numeris::vector![consts::GEO_R, 0.0, 0.0],
&numeris::vector![0.0, (consts::MU_EARTH / consts::GEO_R).sqrt(), 0.0],
);
satstate.set_pos_uncertainty(&numeris::vector![1.0, 1.0, 1.0], Frame::LVLH)?;
let _state2 =
satstate.propagate(&(satstate.time + Duration::from_days(1.0)), None, None)?;
Ok(())
}
#[test]
fn test_zero_duration_propagation() -> Result<()> {
let satstate = SatState::from_pv(
&Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap(),
&numeris::vector![consts::GEO_R, 0.0, 0.0],
&numeris::vector![0.0, (consts::MU_EARTH / consts::GEO_R).sqrt(), 0.0],
);
let state2 = satstate.propagate(&satstate.time, None, None)?;
assert!((satstate.pos_gcrf() - state2.pos_gcrf()).norm() < 1e-15);
assert!((satstate.vel_gcrf() - state2.vel_gcrf()).norm() < 1e-15);
assert_eq!(satstate.time, state2.time);
Ok(())
}
#[test]
fn test_zero_duration_propagation_with_cov() -> Result<()> {
let mut satstate = SatState::from_pv(
&Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap(),
&numeris::vector![consts::GEO_R, 0.0, 0.0],
&numeris::vector![0.0, (consts::MU_EARTH / consts::GEO_R).sqrt(), 0.0],
);
satstate.set_pos_uncertainty(&numeris::vector![1.0, 1.0, 1.0], Frame::LVLH)?;
let state2 = satstate.propagate(&satstate.time, None, None)?;
assert!((satstate.pos_gcrf() - state2.pos_gcrf()).norm() < 1e-15);
assert!((satstate.vel_gcrf() - state2.vel_gcrf()).norm() < 1e-15);
let cov1 = match satstate.cov() {
StateCov::PVCov(v) => v,
StateCov::None => anyhow::bail!("cov is not none"),
};
let cov2 = match state2.cov() {
StateCov::PVCov(v) => v,
StateCov::None => anyhow::bail!("cov is not none"),
};
assert!((cov1 - cov2).norm_inf() < 1e-15);
Ok(())
}
#[test]
fn test_impulsive_maneuver_gcrf() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let t_burn = t0 + Duration::from_hours(1.0);
let t_end = t0 + Duration::from_hours(2.0);
let r = consts::EARTH_RADIUS + 500.0e3;
let v = (consts::MU_EARTH / r).sqrt();
let sat_no_burn = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
let state_no_burn = sat_no_burn.propagate(&t_end, None, None)?;
let mut sat_burn = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
sat_burn.add_maneuver(ImpulsiveManeuver::new(
t_burn,
numeris::vector![0.0, 0.0, 10.0], Frame::GCRF,
));
let state_burn = sat_burn.propagate(&t_end, None, None)?;
let pos_diff = (state_burn.pos_gcrf() - state_no_burn.pos_gcrf()).norm();
assert!(
pos_diff > 100.0,
"Impulsive maneuver should change position: diff = {} m",
pos_diff
);
assert_eq!(state_burn.maneuvers.len(), 1);
Ok(())
}
#[test]
fn test_impulsive_maneuver_ric() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let t_burn = t0 + Duration::from_hours(1.0);
let t_end = t0 + Duration::from_hours(3.0);
let r = consts::EARTH_RADIUS + 500.0e3;
let v = (consts::MU_EARTH / r).sqrt();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
let sat_no_burn = sat.clone();
sat.add_maneuver(ImpulsiveManeuver::new(
t_burn,
numeris::vector![0.0, 10.0, 0.0], Frame::RTN,
));
let state_burn = sat.propagate(&t_end, None, None)?;
let state_no_burn = sat_no_burn.propagate(&t_end, None, None)?;
let r_burn = state_burn.pos_gcrf().norm();
let r_no_burn = state_no_burn.pos_gcrf().norm();
let pos_diff = (state_burn.pos_gcrf() - state_no_burn.pos_gcrf()).norm();
assert!(
pos_diff > 1000.0,
"10 m/s prograde should produce large position diff: {} m",
pos_diff
);
assert!(
(r_burn - r_no_burn).abs() > 100.0,
"Radius should differ: burn={}, no_burn={}",
r_burn,
r_no_burn
);
Ok(())
}
#[test]
fn test_impulsive_maneuver_backward() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let t_burn = t0 + Duration::from_hours(1.0);
let t_end = t0 + Duration::from_hours(2.0);
let r = consts::EARTH_RADIUS + 500.0e3;
let v = (consts::MU_EARTH / r).sqrt();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
sat.add_maneuver(ImpulsiveManeuver::new(
t_burn,
numeris::vector![0.0, 0.0, 5.0], Frame::GCRF,
));
let state_fwd = sat.propagate(&t_end, None, None)?;
let state_back = state_fwd.propagate(&t0, None, None)?;
assert!(
(sat.pos_gcrf() - state_back.pos_gcrf()).norm() < 1.0,
"Backward propagation should recover original position: diff = {} m",
(sat.pos_gcrf() - state_back.pos_gcrf()).norm()
);
assert!(
(sat.vel_gcrf() - state_back.vel_gcrf()).norm() < 0.01,
"Backward propagation should recover original velocity: diff = {} m/s",
(sat.vel_gcrf() - state_back.vel_gcrf()).norm()
);
Ok(())
}
#[test]
fn test_multiple_maneuvers() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let t_burn1 = t0 + Duration::from_hours(1.0);
let t_burn2 = t0 + Duration::from_hours(2.0);
let t_end = t0 + Duration::from_hours(3.0);
let r = consts::EARTH_RADIUS + 500.0e3;
let v = (consts::MU_EARTH / r).sqrt();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
sat.add_maneuver(ImpulsiveManeuver::new(
t_burn1,
numeris::vector![0.0, 0.0, 10.0],
Frame::GCRF,
));
sat.add_maneuver(ImpulsiveManeuver::new(
t_burn2,
numeris::vector![0.0, 0.0, -10.0],
Frame::GCRF,
));
let sat_no_burn = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
let state_burn = sat.propagate(&t_end, None, None)?;
let state_no_burn = sat_no_burn.propagate(&t_end, None, None)?;
let pos_diff = (state_burn.pos_gcrf() - state_no_burn.pos_gcrf()).norm();
assert!(
pos_diff < 50000.0,
"Opposing burns should mostly cancel: diff = {} m",
pos_diff
);
assert_eq!(state_burn.maneuvers.len(), 2);
Ok(())
}
#[test]
fn test_ntw_vs_ric_perpendicular_state() {
let pos: Vector3 = numeris::vector![7_000_000.0, 0.0, 0.0];
let v_mag = (consts::MU_EARTH / pos.norm()).sqrt();
let vel: Vector3 = numeris::vector![0.0, v_mag, 0.0];
let dv_ntw = numeris::vector![0.0, 10.0, 0.0]; let dv_ric = numeris::vector![0.0, 10.0, 0.0];
let ntw_dcm = crate::frametransform::ntw_to_gcrf(&pos, &vel);
let ric_dcm = crate::frametransform::rtn_to_gcrf(&pos, &vel);
let dv_ntw_gcrf = ntw_dcm * dv_ntw;
let dv_ric_gcrf = ric_dcm * dv_ric;
let diff = (dv_ntw_gcrf - dv_ric_gcrf).norm();
assert!(
diff < 1e-12,
"On a r⊥v state, NTW-T and RIC-I should agree exactly; diff = {:.3e}",
diff
);
let expected = vel.normalize() * 10.0;
assert!((dv_ntw_gcrf - expected).norm() < 1e-12);
}
#[test]
fn test_ntw_vs_ric_eccentric_diverge() -> Result<()> {
let a = 8000.0e3;
let e = 0.3;
let nu: f64 = 60.0_f64.to_radians();
let r_mag = a * (1.0 - e * e) / (1.0 + e * nu.cos());
let v_mag = (consts::MU_EARTH * (2.0 / r_mag - 1.0 / a)).sqrt();
let gamma = (e * nu.sin() / (1.0 + e * nu.cos())).atan();
let pos = numeris::vector![r_mag, 0.0, 0.0];
let vel = numeris::vector![
v_mag * gamma.sin(),
v_mag * gamma.cos(),
0.0
];
assert!(
(pos.normalize().dot(&vel.normalize()) - gamma.sin()).abs() < 1e-12
);
let ntw_dcm = crate::frametransform::ntw_to_gcrf(&pos, &vel);
let dv_ntw_gcrf = ntw_dcm * numeris::vector![0.0, 10.0, 0.0];
let v_after_ntw = vel + dv_ntw_gcrf;
let dv_ntw_along_v = v_after_ntw.norm() - vel.norm();
assert!(
(dv_ntw_along_v - 10.0).abs() < 1.0e-6,
"NTW +T burn should add exactly 10 m/s to |v|; got {:.9} m/s",
dv_ntw_along_v
);
let ric_dcm = crate::frametransform::rtn_to_gcrf(&pos, &vel);
let dv_ric_gcrf = ric_dcm * numeris::vector![0.0, 10.0, 0.0];
let v_after_ric = vel + dv_ric_gcrf;
let dv_ric_along_v = v_after_ric.norm() - vel.norm();
assert!(
dv_ric_along_v < 10.0,
"RIC +I burn should add less than 10 m/s to |v|; got {:.6}",
dv_ric_along_v
);
let expected_loss = 10.0 * (1.0 - gamma.cos());
assert!(
(10.0 - dv_ric_along_v - expected_loss).abs() < 1.0e-2,
"RIC loss should be ≈ 10(1-cos γ) = {:.4} m/s; got {:.4}",
expected_loss,
10.0 - dv_ric_along_v
);
assert!((dv_ntw_gcrf.norm() - 10.0).abs() < 1e-12);
assert!((dv_ric_gcrf.norm() - 10.0).abs() < 1e-12);
Ok(())
}
#[test]
fn test_uncertainty_preserves_other_block() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let r = consts::EARTH_RADIUS + 500e3;
let v = (consts::MU_EARTH / r).sqrt();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
sat.set_pos_uncertainty(&numeris::vector![100.0, 200.0, 50.0], Frame::LVLH)?;
sat.set_vel_uncertainty(&numeris::vector![0.1, 0.2, 0.05], Frame::LVLH)?;
let cov = match sat.cov() {
StateCov::PVCov(m) => m,
StateCov::None => panic!("expected covariance"),
};
let pos_block = cov.block::<3, 3>(0, 0);
let vel_block = cov.block::<3, 3>(3, 3);
assert!(pos_block.norm_inf() > 1.0, "position block should be set");
assert!(vel_block.norm_inf() > 0.001, "velocity block should be preserved");
Ok(())
}
#[test]
fn test_uncertainty_supported_frames() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let r = consts::EARTH_RADIUS + 500e3;
let v = (consts::MU_EARTH / r).sqrt();
let sat0 = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
for frame in [Frame::GCRF, Frame::LVLH, Frame::RTN, Frame::NTW] {
let mut sat = sat0.clone();
sat.set_pos_uncertainty(&numeris::vector![10.0, 20.0, 30.0], frame)?;
match sat.cov() {
StateCov::PVCov(m) => {
let block = m.block::<3, 3>(0, 0);
let trace = block[(0, 0)] + block[(1, 1)] + block[(2, 2)];
let expected = 100.0 + 400.0 + 900.0;
assert!(
(trace - expected).abs() / expected < 1e-12,
"{:?}: trace {} expected {}", frame, trace, expected
);
}
StateCov::None => panic!("{:?}: no covariance set", frame),
}
}
Ok(())
}
#[test]
fn test_uncertainty_rejects_unsupported_frames() {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0).unwrap();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![7.0e6, 0.0, 0.0],
&numeris::vector![0.0, 7.5e3, 0.0],
);
for bad_frame in [
Frame::ITRF,
Frame::TIRS,
Frame::CIRS,
Frame::TEME,
Frame::EME2000,
Frame::ICRF,
] {
let res = sat.set_pos_uncertainty(
&numeris::vector![1.0, 1.0, 1.0],
bad_frame,
);
assert!(res.is_err(), "{:?} should be rejected", bad_frame);
}
}
#[test]
fn test_lvlh_matches_ric_with_sign_flips() {
let pos: Vector3 = numeris::vector![7_000_000.0, 0.0, 0.0];
let v_mag = (consts::MU_EARTH / pos.norm()).sqrt();
let vel: Vector3 = numeris::vector![0.0, v_mag, 0.0];
let dv_lvlh: Vector3 = numeris::vector![5.0, 3.0, 2.0];
let dv_ric: Vector3 = numeris::vector![-2.0, 5.0, -3.0];
let lvlh_dcm = crate::frametransform::lvlh_to_gcrf(&pos, &vel);
let ric_dcm = crate::frametransform::rtn_to_gcrf(&pos, &vel);
let dv_lvlh_gcrf = lvlh_dcm * dv_lvlh;
let dv_ric_gcrf = ric_dcm * dv_ric;
let diff = (dv_lvlh_gcrf - dv_ric_gcrf).norm();
assert!(
diff < 1e-10,
"LVLH and equivalent RIC burn should give identical GCRF dv; diff = {:.3e}",
diff
);
}
#[test]
fn test_lvlh_maneuver_end_to_end() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let t_burn = t0 + Duration::from_hours(0.5);
let t_end = t0 + Duration::from_hours(3.0);
let r = consts::EARTH_RADIUS + 500.0e3;
let v = (consts::MU_EARTH / r).sqrt();
let mut sat_lvlh = SatState::from_pv(
&t0,
&numeris::vector![r, 0.0, 0.0],
&numeris::vector![0.0, v, 0.0],
);
let mut sat_ric = sat_lvlh.clone();
sat_lvlh.add_maneuver(ImpulsiveManeuver::new(
t_burn,
numeris::vector![10.0, 0.0, 0.0],
Frame::LVLH,
));
sat_ric.add_maneuver(ImpulsiveManeuver::ric(
t_burn,
numeris::vector![0.0, 10.0, 0.0],
));
let s_lvlh = sat_lvlh.propagate(&t_end, None, None)?;
let s_ric = sat_ric.propagate(&t_end, None, None)?;
let pos_diff = (s_lvlh.pos_gcrf() - s_ric.pos_gcrf()).norm();
assert!(
pos_diff < 1e-6,
"LVLH +x burn and RIC +I burn should give identical propagations; diff = {:.3e} m",
pos_diff
);
Ok(())
}
#[test]
fn test_prograde_constructor_raises_perigee() -> Result<()> {
let t0 = Instant::from_datetime(2015, 3, 20, 0, 0, 0.0)?;
let a = 8000.0e3;
let e = 0.1;
let r_apo = a * (1.0 + e);
let v_apo = (consts::MU_EARTH * (2.0 / r_apo - 1.0 / a)).sqrt();
let mut sat = SatState::from_pv(
&t0,
&numeris::vector![r_apo, 0.0, 0.0],
&numeris::vector![0.0, v_apo, 0.0],
);
let sat_no_burn = sat.clone();
sat.add_maneuver(ImpulsiveManeuver::prograde(t0 + Duration::from_seconds(1.0), 5.0));
let period = 2.0 * std::f64::consts::PI * (a.powi(3) / consts::MU_EARTH).sqrt();
let t_sample = t0 + Duration::from_seconds(period);
let s_burn = sat.propagate(&t_sample, None, None)?;
let s_no_burn = sat_no_burn.propagate(&t_sample, None, None)?;
let diff = (s_burn.pos_gcrf() - s_no_burn.pos_gcrf()).norm();
assert!(
diff > 100.0,
"5 m/s prograde at apogee should produce measurable drift over one orbit: {} m",
diff
);
Ok(())
}
}