use crate::coordinate::Origin;
#[derive(Debug, Clone, PartialEq)]
pub enum SteeringLaw {
ConstantRTN {
alpha_rad: f64,
beta_rad: f64,
},
VelocityTangent,
InertialFixed {
direction: [f64; 3],
},
}
impl SteeringLaw {
fn to_ffi_parts(&self) -> (i32, f64, f64, [f64; 3]) {
match *self {
SteeringLaw::ConstantRTN {
alpha_rad,
beta_rad,
} => (
empyrean_sys::EMPYREAN_STEERING_LAW_CONSTANT_RTN as i32,
alpha_rad,
beta_rad,
[0.0; 3],
),
SteeringLaw::VelocityTangent => (
empyrean_sys::EMPYREAN_STEERING_LAW_VELOCITY_TANGENT as i32,
0.0,
0.0,
[0.0; 3],
),
SteeringLaw::InertialFixed { direction } => (
empyrean_sys::EMPYREAN_STEERING_LAW_INERTIAL_FIXED as i32,
0.0,
0.0,
direction,
),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThrustArc {
pub start_mjd_tdb: f64,
pub end_mjd_tdb: f64,
pub thrust_n: f64,
pub mass_kg: f64,
pub isp_s: Option<f64>,
pub steering: SteeringLaw,
pub sharpness: f64,
pub central_body: Origin,
}
impl ThrustArc {
pub fn new(
start_mjd_tdb: f64,
end_mjd_tdb: f64,
thrust_n: f64,
mass_kg: f64,
sharpness: f64,
steering: SteeringLaw,
central_body: Origin,
) -> Self {
Self {
start_mjd_tdb,
end_mjd_tdb,
thrust_n,
mass_kg,
isp_s: None,
steering,
sharpness,
central_body,
}
}
pub fn with_isp(mut self, isp_s: Option<f64>) -> Self {
self.isp_s = isp_s;
self
}
pub(crate) fn to_ffi(&self) -> empyrean_sys::EmpyreanThrustArc {
let (steering_law, steering_alpha_rad, steering_beta_rad, steering_direction) =
self.steering.to_ffi_parts();
empyrean_sys::EmpyreanThrustArc {
start_mjd_tdb: self.start_mjd_tdb,
end_mjd_tdb: self.end_mjd_tdb,
thrust_n: self.thrust_n,
mass_kg: self.mass_kg,
isp_s: self.isp_s.unwrap_or(f64::NAN),
steering_law,
steering_alpha_rad,
steering_beta_rad,
steering_direction,
sharpness: self.sharpness,
central_body_naif_id: self.central_body.naif_id(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ThrustParams {
pub arcs: Vec<ThrustArc>,
pub dv_corrections: Vec<[f64; 3]>,
pub correction_covariances: Vec<[[f64; 3]; 3]>,
}
impl ThrustParams {
pub fn new(arcs: Vec<ThrustArc>) -> Self {
Self {
arcs,
dv_corrections: Vec::new(),
correction_covariances: Vec::new(),
}
}
pub fn with_dv_corrections(mut self, dv_corrections: Vec<[f64; 3]>) -> Self {
self.dv_corrections = dv_corrections;
self
}
pub fn with_correction_covariances(
mut self,
correction_covariances: Vec<[[f64; 3]; 3]>,
) -> Self {
self.correction_covariances = correction_covariances;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orbit::Orbit;
use crate::{CoordinateState, Epoch, Frame};
fn heliocentric_state() -> CoordinateState {
CoordinateState::cartesian(
Epoch::from_mjd_tdb(59000.0),
[1.0, 0.1, 0.05, -0.005, 0.015, 0.001],
Frame::ICRF,
Origin::SUN,
)
}
fn rtn_arc() -> ThrustArc {
ThrustArc::new(
59000.0,
59010.0,
1000.0,
1000.0,
100.0,
SteeringLaw::ConstantRTN {
alpha_rad: 0.1,
beta_rad: 0.2,
},
Origin::SUN,
)
}
#[test]
fn steering_law_marshals_tag_and_slots() {
let (tag, a, b, d) = SteeringLaw::ConstantRTN {
alpha_rad: 0.1,
beta_rad: 0.2,
}
.to_ffi_parts();
assert_eq!(tag, empyrean_sys::EMPYREAN_STEERING_LAW_CONSTANT_RTN as i32);
assert_eq!((a, b, d), (0.1, 0.2, [0.0; 3]));
let (tag, a, b, d) = SteeringLaw::VelocityTangent.to_ffi_parts();
assert_eq!(
tag,
empyrean_sys::EMPYREAN_STEERING_LAW_VELOCITY_TANGENT as i32
);
assert_eq!((a, b, d), (0.0, 0.0, [0.0; 3]));
let (tag, a, b, d) = SteeringLaw::InertialFixed {
direction: [1.0, 2.0, 3.0],
}
.to_ffi_parts();
assert_eq!(
tag,
empyrean_sys::EMPYREAN_STEERING_LAW_INERTIAL_FIXED as i32
);
assert_eq!((a, b, d), (0.0, 0.0, [1.0, 2.0, 3.0]));
}
#[test]
fn thrust_arc_isp_uses_nan_sentinel() {
let ffi = rtn_arc().to_ffi();
assert!(ffi.isp_s.is_nan());
let ffi = rtn_arc().with_isp(Some(3000.0)).to_ffi();
assert_eq!(ffi.isp_s, 3000.0);
}
#[test]
fn thrust_arc_marshals_all_fields() {
let ffi = rtn_arc().to_ffi();
assert_eq!(ffi.start_mjd_tdb, 59000.0);
assert_eq!(ffi.end_mjd_tdb, 59010.0);
assert_eq!(ffi.thrust_n, 1000.0);
assert_eq!(ffi.mass_kg, 1000.0);
assert_eq!(ffi.sharpness, 100.0);
assert_eq!(ffi.steering_alpha_rad, 0.1);
assert_eq!(ffi.steering_beta_rad, 0.2);
assert_eq!(ffi.central_body_naif_id, Origin::SUN.naif_id());
}
#[test]
fn orbit_without_thrust_leaves_side_arrays_null() {
let orbit = Orbit::new(heliocentric_state());
let (ffi, _keep) = orbit.to_ffi_with_keep().expect("ffi");
assert!(ffi.thrust_arcs.is_null());
assert_eq!(ffi.n_thrust_arcs, 0);
assert!(ffi.dv_corrections.is_null());
assert_eq!(ffi.n_dv_corrections, 0);
assert!(ffi.correction_covariances.is_null());
assert_eq!(ffi.n_correction_covariances, 0);
}
#[test]
fn orbit_with_thrust_populates_side_arrays() {
let cov = [
[1.0e-20, 0.0, 0.0],
[0.0, 2.0e-20, 0.0],
[0.0, 0.0, 3.0e-20],
];
let thrust = ThrustParams::new(vec![rtn_arc()])
.with_dv_corrections(vec![[1.0e-6, 2.0e-6, 3.0e-6]])
.with_correction_covariances(vec![cov]);
let orbit = Orbit::new(heliocentric_state()).with_thrust(Some(thrust));
let (ffi, _keep) = orbit.to_ffi_with_keep().expect("ffi");
assert!(!ffi.thrust_arcs.is_null());
assert_eq!(ffi.n_thrust_arcs, 1);
assert_eq!(ffi.n_dv_corrections, 1);
assert_eq!(ffi.n_correction_covariances, 1);
let arcs = unsafe { std::slice::from_raw_parts(ffi.thrust_arcs, ffi.n_thrust_arcs) };
assert_eq!(arcs[0].thrust_n, 1000.0);
assert_eq!(
arcs[0].steering_law,
empyrean_sys::EMPYREAN_STEERING_LAW_CONSTANT_RTN as i32
);
let dvs = unsafe { std::slice::from_raw_parts(ffi.dv_corrections, ffi.n_dv_corrections) };
assert_eq!(dvs[0], [1.0e-6, 2.0e-6, 3.0e-6]);
let covs = unsafe {
std::slice::from_raw_parts(ffi.correction_covariances, ffi.n_correction_covariances)
};
assert_eq!(covs[0], cov);
}
#[test]
fn empty_thrust_params_send_no_side_arrays() {
let orbit = Orbit::new(heliocentric_state()).with_thrust(Some(ThrustParams::default()));
let (ffi, _keep) = orbit.to_ffi_with_keep().expect("ffi");
assert!(ffi.thrust_arcs.is_null());
assert_eq!(ffi.n_thrust_arcs, 0);
}
}
#[cfg(test)]
mod propagate_tests {
use super::*;
use crate::orbit::Orbit;
use crate::{Context, CoordinateState, Epoch, Frame, PropagationConfig};
use std::path::PathBuf;
fn try_context() -> Option<Context> {
let candidates = [
std::env::var("EMPYREAN_DATA_DIR").ok().map(PathBuf::from),
std::env::var("HOME")
.ok()
.map(|h| PathBuf::from(h).join(".empyrean/data")),
];
for dir in candidates.into_iter().flatten() {
if let Ok(ctx) = Context::from_data_dir(Some(&dir)) {
return Some(ctx);
}
}
None
}
fn seeded_state() -> CoordinateState {
let mut cov = [[0.0_f64; 6]; 6];
for (i, row) in cov.iter_mut().enumerate() {
row[i] = 1.0e-16;
}
CoordinateState::cartesian(
Epoch::from_mjd_tdb(59000.0),
[1.0, 0.1, 0.05, -0.005, 0.015, 0.001],
Frame::ICRF,
Origin::SUN,
)
.with_covariance(cov)
}
fn rtn_arc() -> ThrustArc {
ThrustArc::new(
59000.0,
59010.0,
1000.0,
1000.0,
100.0,
SteeringLaw::ConstantRTN {
alpha_rad: 0.1,
beta_rad: 0.2,
},
Origin::SUN,
)
}
#[test]
fn propagate_with_thrust_perturbs_trajectory() {
let Some(ctx) = try_context() else {
eprintln!("skipping propagate_with_thrust_perturbs_trajectory: no data dir");
return;
};
let eye_small = [
[1.0e-20, 0.0, 0.0],
[0.0, 1.0e-20, 0.0],
[0.0, 0.0, 1.0e-20],
];
let thrust = ThrustParams::new(vec![rtn_arc()])
.with_dv_corrections(vec![[0.0, 0.0, 0.0]])
.with_correction_covariances(vec![eye_small]);
let thrusted = Orbit::new(seeded_state())
.with_orbit_id("thrust-loop")
.with_thrust(Some(thrust));
let ballistic = Orbit::new(seeded_state()).with_orbit_id("ballistic");
let epochs: Vec<Epoch> = [59000.0, 59005.0, 59012.0]
.iter()
.map(|&d| Epoch::from_mjd_tdb(d))
.collect();
let mut cfg = PropagationConfig::default();
cfg.advanced.cache_integrator_steps = true;
let with_thrust = ctx
.propagate(&[thrusted], &epochs, &cfg)
.expect("propagate with thrust must succeed");
let without_thrust = ctx
.propagate(&[ballistic], &epochs, &cfg)
.expect("ballistic propagate must succeed");
assert!(!with_thrust.states.is_empty(), "expected propagated states");
with_thrust
.covariance_at_cartesian(0, epochs.len() - 1)
.expect("tagged-covariance readback for a thrust orbit");
let a = with_thrust.states.last().unwrap().position;
let b = without_thrust.states.last().unwrap().position;
let delta = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt();
assert!(
delta > 1.0e-3,
"thrust arc must perturb the trajectory (Δposition = {delta:e} AU)"
);
}
#[test]
fn thrust_correction_covariance_mismatch_surfaces_loudly() {
let Some(ctx) = try_context() else {
eprintln!(
"skipping thrust_correction_covariance_mismatch_surfaces_loudly: no data dir"
);
return;
};
let eye = [
[1.0e-20, 0.0, 0.0],
[0.0, 1.0e-20, 0.0],
[0.0, 0.0, 1.0e-20],
];
let thrust = ThrustParams::new(vec![rtn_arc()]).with_correction_covariances(vec![eye]);
let orbit = Orbit::new(seeded_state()).with_thrust(Some(thrust));
let epochs = vec![Epoch::from_mjd_tdb(59000.0), Epoch::from_mjd_tdb(59005.0)];
let cfg = PropagationConfig::default();
let err = ctx
.propagate(&[orbit], &epochs, &cfg)
.expect_err("mismatched correction_covariances must error, not degrade");
assert!(
err.message.contains("correction_covariances"),
"error must name the offending field, got: {}",
err.message
);
}
}