use crate::body::Body;
use crate::ephem::{moon_position, sun_position};
use crate::forces::{
drag_accel, j2_accel, lense_thirring_accel, relativistic_accel, srp_accel, third_body_accel,
two_body_accel_body, zonal_accel, EARTH_ZONALS_J2_J6, MU_EARTH, MU_MOON, MU_SUN,
};
use crate::integrator::{integrate, integrate_dopri, rk4_step, Tolerance};
use crate::precession::julian_centuries_tt;
use crate::sgp4::Sgp4;
use crate::timescales::SECONDS_PER_DAY;
type Vec3 = [f64; 3];
fn cross(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn norm(a: Vec3) -> f64 {
dot(a, a).sqrt()
}
#[derive(Clone, Debug, Default)]
pub struct ForceModel {
pub body: Body,
pub j2: bool,
pub zonals: Option<&'static [f64]>,
pub sun: bool,
pub moon: bool,
pub epoch_jd_tt: f64,
pub srp: bool,
pub cr: f64,
pub area_over_mass: f64,
pub drag: bool,
pub cd_area_over_mass: f64,
pub relativity: bool,
pub lense_thirring: bool,
pub tides: bool,
}
impl ForceModel {
pub fn two_body() -> Self {
Self::default()
}
pub fn with_j2() -> Self {
Self {
j2: true,
..Self::default()
}
}
pub fn with_zonals_j2_j6() -> Self {
Self {
j2: true,
zonals: Some(&EARTH_ZONALS_J2_J6),
..Self::default()
}
}
pub fn third_body(mut self, sun: bool, moon: bool, epoch_jd_tt: f64) -> Self {
self.sun = sun;
self.moon = moon;
self.epoch_jd_tt = epoch_jd_tt;
self
}
pub fn solar_radiation(mut self, cr: f64, area_over_mass: f64) -> Self {
self.srp = true;
self.cr = cr;
self.area_over_mass = area_over_mass;
self
}
pub fn drag(mut self, cd_area_over_mass: f64) -> Self {
self.drag = true;
self.cd_area_over_mass = cd_area_over_mass;
self
}
pub fn relativity(mut self) -> Self {
self.relativity = true;
self
}
pub fn lense_thirring(mut self) -> Self {
self.lense_thirring = true;
self
}
pub fn tides(mut self) -> Self {
self.tides = true;
self
}
pub fn with_body(mut self, body: Body) -> Self {
self.body = body;
self
}
pub fn accel(&self, r: Vec3) -> Vec3 {
self.central_accel_at(self.epoch_jd_tt, r)
}
fn central_accel_at(&self, jd_tt: f64, r: Vec3) -> Vec3 {
if let Some(field) = &self.body.gravity {
let r_bf = crate::mars_frame::inertial_to_bodyfixed(r, &self.body, jd_tt);
let a_bf = field.acceleration(r_bf);
return crate::mars_frame::bodyfixed_to_inertial(a_bf, &self.body, jd_tt);
}
let tb = two_body_accel_body(r, &self.body);
if let Some(jn) = self.zonals {
let zo = zonal_accel(r, jn);
[tb[0] + zo[0], tb[1] + zo[1], tb[2] + zo[2]]
} else if self.j2 {
let j = j2_accel(r);
[tb[0] + j[0], tb[1] + j[1], tb[2] + j[2]]
} else {
tb
}
}
pub fn accel_at(&self, t: f64, r: Vec3) -> Vec3 {
let jd_tt = self.epoch_jd_tt + t / SECONDS_PER_DAY;
let mut a = self.central_accel_at(jd_tt, r);
if self.sun || self.moon || self.srp {
let tjc = julian_centuries_tt(jd_tt);
let sun = if self.sun || self.srp {
Some(sun_position(tjc))
} else {
None
};
if self.sun {
let sun = sun.expect(
"`sun` is Some whenever `self.sun || self.srp`, and `self.sun` holds here",
);
let p = third_body_accel(r, sun, MU_SUN);
a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
}
if self.moon {
let p = third_body_accel(r, moon_position(tjc), MU_MOON);
a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
}
if self.srp {
let sun = sun.expect(
"`sun` is Some whenever `self.sun || self.srp`, and `self.srp` holds here",
);
let p = srp_accel(r, sun, self.cr, self.area_over_mass);
a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
}
}
if self.tides {
let p = crate::tides::tidal_acceleration(r, jd_tt);
a = [a[0] + p[0], a[1] + p[1], a[2] + p[2]];
}
a
}
pub fn accel_rv(&self, t: f64, r: Vec3, v: Vec3) -> Vec3 {
let mut a = self.accel_at(t, r);
if self.drag {
let d = drag_accel(r, v, self.cd_area_over_mass);
a = [a[0] + d[0], a[1] + d[1], a[2] + d[2]];
}
if self.relativity {
let g = relativistic_accel(r, v);
a = [a[0] + g[0], a[1] + g[1], a[2] + g[2]];
}
if self.lense_thirring {
let g = lense_thirring_accel(r, v);
a = [a[0] + g[0], a[1] + g[1], a[2] + g[2]];
}
a
}
fn rhs(&self) -> impl Fn(f64, &[f64]) -> Vec<f64> + '_ {
move |t: f64, y: &[f64]| {
let a = self.accel_rv(t, [y[0], y[1], y[2]], [y[3], y[4], y[5]]);
vec![y[3], y[4], y[5], a[0], a[1], a[2]]
}
}
}
fn state_is_finite(r0: Vec3, v0: Vec3, t_end: f64) -> bool {
t_end.is_finite() && r0.iter().all(|x| x.is_finite()) && v0.iter().all(|x| x.is_finite())
}
pub fn propagate(
r0: Vec3,
v0: Vec3,
t_end: f64,
model: &ForceModel,
tol: &Tolerance,
) -> (Vec3, Vec3) {
if !state_is_finite(r0, v0, t_end) {
return (r0, v0);
}
let f = model.rhs();
let y0 = vec![r0[0], r0[1], r0[2], v0[0], v0[1], v0[2]];
let h0 = (t_end / 1000.0).max(1.0).min(t_end.max(1e-3));
let sol = integrate(&f, 0.0, &y0, t_end, h0, tol);
(
[sol.y[0], sol.y[1], sol.y[2]],
[sol.y[3], sol.y[4], sol.y[5]],
)
}
pub fn propagate_dopri(
r0: Vec3,
v0: Vec3,
t_end: f64,
model: &ForceModel,
tol: &Tolerance,
) -> (Vec3, Vec3) {
if !state_is_finite(r0, v0, t_end) {
return (r0, v0);
}
let f = model.rhs();
let y0 = vec![r0[0], r0[1], r0[2], v0[0], v0[1], v0[2]];
let h0 = (t_end / 1000.0).max(1.0).min(t_end.max(1e-3));
let sol = integrate_dopri(&f, 0.0, &y0, t_end, h0, tol);
(
[sol.y[0], sol.y[1], sol.y[2]],
[sol.y[3], sol.y[4], sol.y[5]],
)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StateVector {
pub r: Vec3,
pub v: Vec3,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PropagatorError {
Sgp4(i32),
}
impl core::fmt::Display for PropagatorError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
PropagatorError::Sgp4(code) => write!(f, "SGP4 propagation error (code {code})"),
}
}
}
impl std::error::Error for PropagatorError {}
pub trait Propagator {
fn state_at(&self, t_seconds: f64) -> Result<StateVector, PropagatorError>;
fn model_name(&self) -> &'static str;
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Integrator {
#[default]
StepDoubling,
DormandPrince,
}
#[derive(Clone, Debug)]
pub struct NumericalPropagator {
pub r0: Vec3,
pub v0: Vec3,
pub model: ForceModel,
pub tol: Tolerance,
pub integrator: Integrator,
}
impl NumericalPropagator {
pub fn two_body(r0: Vec3, v0: Vec3) -> Self {
Self {
r0,
v0,
model: ForceModel::two_body(),
tol: Tolerance::default(),
integrator: Integrator::default(),
}
}
pub fn with_model(mut self, model: ForceModel) -> Self {
self.model = model;
self
}
pub fn with_tolerance(mut self, tol: Tolerance) -> Self {
self.tol = tol;
self
}
pub fn with_integrator(mut self, integrator: Integrator) -> Self {
self.integrator = integrator;
self
}
}
impl Propagator for NumericalPropagator {
fn state_at(&self, t_seconds: f64) -> Result<StateVector, PropagatorError> {
let (r, v) = match self.integrator {
Integrator::StepDoubling => {
propagate(self.r0, self.v0, t_seconds, &self.model, &self.tol)
}
Integrator::DormandPrince => {
propagate_dopri(self.r0, self.v0, t_seconds, &self.model, &self.tol)
}
};
Ok(StateVector { r, v })
}
fn model_name(&self) -> &'static str {
"numerical-cowell"
}
}
impl Propagator for Sgp4 {
fn state_at(&self, t_seconds: f64) -> Result<StateVector, PropagatorError> {
let (r_km, v_km) = self
.propagate(t_seconds / 60.0)
.map_err(PropagatorError::Sgp4)?;
Ok(StateVector {
r: [r_km[0] * 1000.0, r_km[1] * 1000.0, r_km[2] * 1000.0],
v: [v_km[0] * 1000.0, v_km[1] * 1000.0, v_km[2] * 1000.0],
})
}
fn model_name(&self) -> &'static str {
"sgp4"
}
}
pub fn raan_rad(r: Vec3, v: Vec3) -> f64 {
let h = cross(r, v);
let n = [-h[1], h[0], 0.0];
n[1].atan2(n[0])
}
pub fn specific_energy(r: Vec3, v: Vec3) -> f64 {
0.5 * dot(v, v) - MU_EARTH / norm(r)
}
pub fn nodal_history(
r0: Vec3,
v0: Vec3,
t_end: f64,
h: f64,
model: &ForceModel,
) -> Vec<(f64, f64)> {
let f = model.rhs();
let mut y = vec![r0[0], r0[1], r0[2], v0[0], v0[1], v0[2]];
let mut t = 0.0;
let mut out = vec![(0.0, raan_rad(r0, v0))];
while t < t_end - 1e-9 {
y = rk4_step(&f, t, &y, h);
t += h;
out.push((t, raan_rad([y[0], y[1], y[2]], [y[3], y[4], y[5]])));
}
out
}
pub fn secular_slope(samples: &[(f64, f64)]) -> f64 {
let mut theta = Vec::with_capacity(samples.len());
let mut prev = samples[0].1;
let mut offset = 0.0;
for &(_, th) in samples {
let mut d = th - prev;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d < -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
offset += d;
theta.push(samples[0].1 + offset);
prev = th;
}
let n = samples.len() as f64;
let mean_t = samples.iter().map(|&(t, _)| t).sum::<f64>() / n;
let mean_y = theta.iter().sum::<f64>() / n;
let mut num = 0.0;
let mut den = 0.0;
for (i, &(t, _)) in samples.iter().enumerate() {
num += (t - mean_t) * (theta[i] - mean_y);
den += (t - mean_t) * (t - mean_t);
}
num / den
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct KeplerNonConvergence {
pub residual: f64,
pub iters: usize,
}
pub fn solve_kepler_checked(
mean_anomaly: f64,
e: f64,
max_iter: usize,
) -> Result<f64, KeplerNonConvergence> {
const TOL: f64 = 1e-12;
if e == 0.0 {
return Ok(mean_anomaly);
}
let mut ea = mean_anomaly;
for _ in 0..max_iter {
let residual = ea - e * ea.sin() - mean_anomaly;
if residual.abs() < TOL {
return Ok(ea);
}
let deriv = 1.0 - e * ea.cos();
ea -= residual / deriv;
}
let final_residual = (ea - e * ea.sin() - mean_anomaly).abs();
if final_residual < TOL {
Ok(ea)
} else {
Err(KeplerNonConvergence {
residual: final_residual,
iters: max_iter,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::forces::j2_secular_rates;
use crate::maneuver::kepler_universal;
fn leo_state() -> (Vec3, Vec3) {
let a = 7.0e6;
let v = (MU_EARTH / a).sqrt(); let inc = 45.0_f64.to_radians();
let r0 = [a, 0.0, 0.0];
let v0 = [0.0, v * inc.cos(), v * inc.sin()];
(r0, v0)
}
#[test]
fn two_body_propagation_matches_the_exact_kepler_solution_over_24h() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let (r_num, _v_num) = propagate(r0, v0, day, &ForceModel::two_body(), &tol);
let (r_exact, _v_exact) = kepler_universal(r0, v0, day, MU_EARTH);
let err = norm([
r_num[0] - r_exact[0],
r_num[1] - r_exact[1],
r_num[2] - r_exact[2],
]);
assert!(err < 10.0, "24h two-body residual {err} m vs exact Kepler");
assert!(err < 1.0, "should be sub-metre: {err} m");
}
#[test]
fn dopri_propagation_matches_exact_kepler_and_agrees_with_the_rk4_path() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let (r_dp, _) = propagate_dopri(r0, v0, day, &ForceModel::two_body(), &tol);
let (r_exact, _) = kepler_universal(r0, v0, day, MU_EARTH);
let err = norm([
r_dp[0] - r_exact[0],
r_dp[1] - r_exact[1],
r_dp[2] - r_exact[2],
]);
assert!(
err < 1.0,
"DP5(4) 24h two-body residual {err} m vs exact Kepler"
);
let (r_rk4, _) = propagate(r0, v0, day, &ForceModel::with_zonals_j2_j6(), &tol);
let (r_dp2, _) = propagate_dopri(r0, v0, day, &ForceModel::with_zonals_j2_j6(), &tol);
let agree = norm([
r_rk4[0] - r_dp2[0],
r_rk4[1] - r_dp2[1],
r_rk4[2] - r_dp2[2],
]);
assert!(
agree < 1.0,
"step-doubling and DP5(4) must agree on the J2..J6 orbit: {agree} m"
);
}
#[test]
fn two_body_conserves_energy_and_angular_momentum() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let e0 = specific_energy(r0, v0);
let h0 = norm(cross(r0, v0));
let (r1, v1) = propagate(r0, v0, day, &ForceModel::two_body(), &tol);
let e1 = specific_energy(r1, v1);
let h1 = norm(cross(r1, v1));
assert!(
(e1 - e0).abs() / e0.abs() < 1e-9,
"energy drift {}",
e1 - e0
);
assert!(
(h1 - h0).abs() / h0 < 1e-9,
"ang-momentum drift {}",
h1 - h0
);
}
#[test]
fn j2_nodal_regression_reproduces_the_secular_formula() {
let a = 6.778e6; let inc = 51.6_f64.to_radians();
let v = (MU_EARTH / a).sqrt();
let r0 = [a, 0.0, 0.0];
let v0 = [0.0, v * inc.cos(), v * inc.sin()];
let day = 86_400.0;
let hist = nodal_history(r0, v0, day, 10.0, &ForceModel::with_j2());
let rate_num = secular_slope(&hist);
let rate_formula = j2_secular_rates(a, 0.0, inc).raan;
assert!(rate_num < 0.0 && rate_formula < 0.0);
let rel = (rate_num - rate_formula).abs() / rate_formula.abs();
assert!(
rel < 0.02,
"numerical Ω̇ {rate_num} vs formula {rate_formula} (rel {rel})"
);
let deg_per_day = rate_formula.to_degrees() * day;
assert!((deg_per_day + 5.0).abs() < 0.6, "Ω̇ {deg_per_day} °/day");
}
#[test]
fn solve_kepler_converges_on_a_well_conditioned_case() {
let m = 1.0;
let e = 0.3;
let ea = solve_kepler_checked(m, e, 30).expect("should converge");
assert!((ea - e * ea.sin() - m).abs() < 1e-12);
assert_eq!(solve_kepler_checked(0.7, 0.0, 30), Ok(0.7));
}
#[test]
fn solve_kepler_returns_err_on_nonconvergence_at_high_eccentricity() {
let result = solve_kepler_checked(0.3, 0.999, 30);
assert!(
result.is_err(),
"expected non-convergence Err, got {result:?}"
);
if let Err(nc) = result {
assert_eq!(nc.iters, 30);
assert!(nc.residual > 1e-12);
}
}
#[test]
fn third_body_rhs_samples_the_ephemeris_at_the_advanced_epoch() {
use crate::timescales::JD_J2000;
let epoch = JD_J2000; let model = ForceModel::two_body().third_body(true, false, epoch);
let r = [7.0e6, 1.0e6, -2.0e6];
let central = model.accel(r);
let a0 = model.accel_at(0.0, r);
let sun0 = sun_position(julian_centuries_tt(epoch));
let expect0 = third_body_accel(r, sun0, MU_SUN);
for k in 0..3 {
assert_eq!(
a0[k],
central[k] + expect0[k],
"t=0 Sun term mismatch axis {k}"
);
}
let a1 = model.accel_at(SECONDS_PER_DAY, r);
let sun1 = sun_position(julian_centuries_tt(epoch + 1.0));
let expect1 = third_body_accel(r, sun1, MU_SUN);
for k in 0..3 {
assert_eq!(
a1[k],
central[k] + expect1[k],
"t=1day Sun term mismatch axis {k}"
);
}
let moved = norm([sun1[0] - sun0[0], sun1[1] - sun0[1], sun1[2] - sun0[2]]);
assert!(
moved > 1e9,
"Sun should advance ~2.6e9 m in a day, moved {moved} m"
);
let plain = ForceModel::with_j2();
for &t in &[0.0, 1234.0, SECONDS_PER_DAY] {
assert_eq!(plain.accel_at(t, r), plain.accel(r));
}
}
#[test]
fn third_body_perturbs_a_leo_orbit_at_the_textbook_magnitudes() {
use crate::timescales::JD_J2000;
let (r0, v0) = leo_state();
let day = 86_400.0;
let epoch = JD_J2000;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let central = ForceModel::two_body().accel(r0);
let tidal = |m: ForceModel| {
let a = m.accel_at(0.0, r0);
norm([a[0] - central[0], a[1] - central[1], a[2] - central[2]])
};
let a_sun = tidal(ForceModel::two_body().third_body(true, false, epoch));
let a_moon = tidal(ForceModel::two_body().third_body(false, true, epoch));
assert!(
(2.0e-7..=8.0e-7).contains(&a_sun),
"Sun tidal accel {a_sun} m/s² outside ~5e-7 band"
);
assert!(
(5.0e-7..=2.0e-6).contains(&a_moon),
"Moon tidal accel {a_moon} m/s² outside ~1.1e-6 band"
);
assert!(
a_moon > a_sun,
"Moon tidal accel ({a_moon}) must exceed the Sun's ({a_sun})"
);
let (r_tb, _) = propagate(r0, v0, day, &ForceModel::two_body(), &tol);
let sep = |m: &ForceModel| {
let (r, _) = propagate(r0, v0, day, m, &tol);
norm([r[0] - r_tb[0], r[1] - r_tb[1], r[2] - r_tb[2]])
};
for (name, m) in [
("Sun", ForceModel::two_body().third_body(true, false, epoch)),
(
"Moon",
ForceModel::two_body().third_body(false, true, epoch),
),
] {
let s = sep(&m);
assert!(
s > 1e-3,
"{name} third body must perturb the orbit, sep {s} m"
);
assert!(
s < 1e5,
"{name} third body must stay a small perturbation, sep {s} m"
);
}
}
#[test]
fn third_body_propagation_depends_on_the_epoch() {
use crate::timescales::JD_J2000;
let (r0, v0) = leo_state();
let arc = 2.0 * 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let prop = |epoch: f64| {
propagate(
r0,
v0,
arc,
&ForceModel::two_body().third_body(true, false, epoch),
&tol,
)
.0
};
let r_jan = prop(JD_J2000); let r_apr = prop(JD_J2000 + 91.31); let diff = norm([
r_jan[0] - r_apr[0],
r_jan[1] - r_apr[1],
r_jan[2] - r_apr[2],
]);
assert!(
diff > 1e-3,
"epoch must change the trajectory, diff {diff} m"
);
assert!(diff < 1e5, "epoch effect must stay bounded, diff {diff} m");
}
#[test]
fn srp_perturbs_a_leo_orbit_and_scales_linearly_with_area_to_mass() {
use crate::timescales::JD_J2000;
let (r0, v0) = leo_state();
let day = 86_400.0;
let epoch = JD_J2000;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let base = ForceModel::with_zonals_j2_j6().third_body(true, true, epoch);
let (r_base, _) = propagate(r0, v0, day, &base, &tol);
let sep = |aom: f64| {
let (r, _) = propagate(r0, v0, day, &base.clone().solar_radiation(1.5, aom), &tol);
norm([r[0] - r_base[0], r[1] - r_base[1], r[2] - r_base[2]])
};
let s1 = sep(0.02);
let s2 = sep(0.04);
assert!(s1 > 1e-3, "SRP must perturb the orbit, sep {s1} m");
assert!(s1 < 1e4, "SRP must stay a small perturbation, sep {s1} m");
let ratio = s2 / s1;
assert!(
(1.8..=2.2).contains(&ratio),
"SRP displacement should scale ~linearly with A/m, ratio {ratio}"
);
}
#[test]
fn drag_dissipates_energy_and_decays_the_orbit_monotonically() {
let alt = 300e3;
let r0 = [crate::forces::RE_EARTH + alt, 0.0, 0.0];
let vc = (MU_EARTH / (crate::forces::RE_EARTH + alt)).sqrt();
let inc = 45.0_f64.to_radians();
let v0 = [0.0, vc * inc.cos(), vc * inc.sin()];
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let drag_model = ForceModel::two_body().drag(0.02);
let e0 = specific_energy(r0, v0);
let (r_vac, v_vac) = propagate(r0, v0, day, &ForceModel::two_body(), &tol);
assert!(
(specific_energy(r_vac, v_vac) - e0).abs() / e0.abs() < 1e-9,
"vacuum orbit must conserve energy"
);
let mut prev = e0;
let mut e_day = e0;
for k in 1..=4 {
let (r, v) = propagate(r0, v0, day * f64::from(k) / 4.0, &drag_model, &tol);
e_day = specific_energy(r, v);
assert!(
e_day < prev,
"drag energy must decay monotonically: step {k} e {e_day} not < prev {prev}"
);
prev = e_day;
}
let a0 = -MU_EARTH / (2.0 * e0);
let a_day = -MU_EARTH / (2.0 * e_day);
assert!(
a_day < a0,
"drag must shrink the semi-major axis: a_day {a_day} not < a0 {a0}"
);
let drop = a0 - a_day;
assert!(
(1.0..=1e5).contains(&drop),
"a-decay {drop} m/day outside the physical band for 300 km, C_D·A/m = 0.02"
);
}
#[test]
fn relativity_perturbs_the_orbit_without_dissipating_it() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let base = ForceModel::two_body();
let (r_base, _) = propagate(r0, v0, day, &base, &tol);
let (r_gr, v_gr) = propagate(r0, v0, day, &base.relativity(), &tol);
let sep = norm([
r_gr[0] - r_base[0],
r_gr[1] - r_base[1],
r_gr[2] - r_base[2],
]);
assert!(sep > 1e-4, "relativity must perturb the orbit, sep {sep} m");
assert!(
sep < 1e5,
"relativity must stay a tiny perturbation, sep {sep} m"
);
let a0 = -MU_EARTH / (2.0 * specific_energy(r0, v0));
let a_gr = -MU_EARTH / (2.0 * specific_energy(r_gr, v_gr));
assert!(
(a_gr - a0).abs() < 10.0,
"relativity must not decay the semi-major axis: Δa {} m",
a_gr - a0
);
}
#[test]
fn zonal_j2_j6_orbit_conserves_energy_and_perturbs_the_j2_orbit() {
use crate::forces::zonal_potential;
let total =
|r: Vec3, v: Vec3| specific_energy(r, v) - zonal_potential(r, &EARTH_ZONALS_J2_J6);
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let e0 = total(r0, v0);
let (r_day, v_day) = propagate(r0, v0, day, &ForceModel::with_zonals_j2_j6(), &tol);
let e1 = total(r_day, v_day);
assert!(
(e1 - e0).abs() / e0.abs() < 1e-8,
"zonal field is conservative; total-energy drift {}",
e1 - e0
);
let period = 5400.0;
let (r_zon, _) = propagate(r0, v0, period, &ForceModel::with_zonals_j2_j6(), &tol);
let (r_j2, _) = propagate(r0, v0, period, &ForceModel::with_j2(), &tol);
let sep = norm([r_zon[0] - r_j2[0], r_zon[1] - r_j2[1], r_zon[2] - r_j2[2]]);
assert!(
sep > 1e-3,
"J3..J6 must perturb the orbit, separation {sep} m"
);
assert!(
sep < 5.0e4,
"J3..J6 must stay a small correction, separation {sep} m"
);
}
#[test]
fn lense_thirring_flag_perturbs_the_trajectory_minutely() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let base = ForceModel::two_body();
let (r_base, _) = propagate(r0, v0, day, &base, &tol);
let (r_lt, _) = propagate(r0, v0, day, &base.lense_thirring(), &tol);
let sep = norm([
r_lt[0] - r_base[0],
r_lt[1] - r_base[1],
r_lt[2] - r_base[2],
]);
assert!(sep > 0.0 && sep < 100.0, "LT day-long perturbation {sep} m");
}
fn iss_like_tle() -> crate::tle::Tle {
crate::tle::Tle {
epoch_days_1950: 26000.0,
bstar: 1.0e-4,
ecco: 0.0007,
argpo_rad: 1.0,
inclo_rad: 51.64_f64.to_radians(),
mo_rad: 2.0,
no_kozai_rad_min: 15.50 * std::f64::consts::TAU / 1440.0,
nodeo_rad: 0.5,
}
}
#[test]
fn numerical_propagator_trait_matches_exact_kepler() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let prop = NumericalPropagator::two_body(r0, v0).with_tolerance(Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
});
let s = prop
.state_at(day)
.expect("two-body propagation is infallible");
let (r_exact, _) = kepler_universal(r0, v0, day, MU_EARTH);
let err = norm([
s.r[0] - r_exact[0],
s.r[1] - r_exact[1],
s.r[2] - r_exact[2],
]);
assert!(err < 1.0, "trait two-body residual {err} m vs exact Kepler");
assert_eq!(prop.model_name(), "numerical-cowell");
}
#[test]
fn numerical_propagator_dormand_prince_selection_agrees() {
let (r0, v0) = leo_state();
let day = 86_400.0;
let tol = Tolerance {
rtol: 1e-12,
atol: 1e-9,
..Tolerance::default()
};
let sd = NumericalPropagator::two_body(r0, v0)
.with_tolerance(tol)
.state_at(day)
.unwrap();
let dp = NumericalPropagator::two_body(r0, v0)
.with_tolerance(tol)
.with_integrator(Integrator::DormandPrince)
.state_at(day)
.unwrap();
let sep = norm([sd.r[0] - dp.r[0], sd.r[1] - dp.r[1], sd.r[2] - dp.r[2]]);
assert!(
sep < 1.0,
"the two adaptive drivers must agree, sep {sep} m"
);
}
#[test]
fn sgp4_propagator_trait_matches_inherent_method_in_si() {
let sgp4 = iss_like_tle().to_sgp4(crate::sgp4::wgs72(), false);
let t_s = 3600.0;
let (r_km, v_km) = sgp4.propagate(t_s / 60.0).unwrap();
let s = Propagator::state_at(&sgp4, t_s).unwrap();
for k in 0..3 {
assert_eq!(s.r[k], r_km[k] * 1000.0, "position component {k}");
assert_eq!(s.v[k], v_km[k] * 1000.0, "velocity component {k}");
}
assert_eq!(sgp4.model_name(), "sgp4");
}
#[test]
fn propagators_are_object_safe_and_polymorphic() {
let (r0, v0) = leo_state();
let props: Vec<Box<dyn Propagator>> = vec![
Box::new(NumericalPropagator::two_body(r0, v0)),
Box::new(iss_like_tle().to_sgp4(crate::sgp4::wgs72(), false)),
];
for p in &props {
let s = p.state_at(600.0).expect("both propagate 10 min fine");
assert!(s.r.iter().all(|x| x.is_finite()) && norm(s.r) > 6.0e6);
assert!(!p.model_name().is_empty());
}
}
#[test]
fn non_finite_initial_state_returns_without_hanging() {
let nan = [f64::NAN, 0.0, 0.0];
let v = [0.0, 7546.0, 0.0];
let (r, _) = propagate(
nan,
v,
100.0,
&ForceModel::two_body(),
&Tolerance::default(),
);
assert!(r[0].is_nan(), "NaN in → NaN out, not a hang");
let s = NumericalPropagator::two_body(nan, v)
.state_at(100.0)
.unwrap();
assert!(s.r[0].is_nan());
}
#[test]
fn propagator_error_displays_the_sgp4_code() {
let e = PropagatorError::Sgp4(6);
assert!(
format!("{e}").contains('6'),
"Display should name the code: {e}"
);
}
}