use crate::body::MOON_ZONALS_J2_J3;
use crate::ephem::{moon_position, sun_position};
use crate::forces::{third_body_accel, MU_EARTH, MU_SUN};
use crate::integrator::{integrate, rk4_step, Tolerance};
use crate::lunar::{mci_to_mcmf, mcmf_to_mci, MOON_GM_M3_S2, R_MOON_M};
use crate::portable_math::PortableFloat;
use crate::timescales::SECONDS_PER_DAY;
type Vec3 = [f64; 3];
pub const MOON_J2: f64 = MOON_ZONALS_J2_J3[0];
pub const MOON_C22: f64 = 2.2382e-5;
pub const MOON_S22: f64 = 0.0;
pub const MOON_REF_RADIUS_M: f64 = R_MOON_M;
pub const MOON_MU_M3_S2: f64 = MOON_GM_M3_S2;
const JULIAN_CENTURY_S: f64 = 36_525.0 * SECONDS_PER_DAY;
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()
}
fn unit(a: Vec3) -> Vec3 {
let n = norm(a);
[a[0] / n, a[1] / n, a[2] / n]
}
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 add(a: Vec3, b: Vec3) -> Vec3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn sub(a: Vec3, b: Vec3) -> Vec3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn scale(a: Vec3, k: f64) -> Vec3 {
[a[0] * k, a[1] * k, a[2] * k]
}
fn rotz(v: Vec3, angle: f64) -> Vec3 {
let (s, c) = angle.psin_cos();
[c * v[0] - s * v[1], s * v[0] + c * v[1], v[2]]
}
fn rotx(v: Vec3, angle: f64) -> Vec3 {
let (s, c) = angle.psin_cos();
[v[0], c * v[1] - s * v[2], s * v[1] + c * v[2]]
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LunarState {
pub r: Vec3,
pub v: Vec3,
}
pub fn mean_motion(a: f64) -> f64 {
(MOON_MU_M3_S2 / (a * a * a)).sqrt()
}
pub fn elements_to_state(
sma_m: f64,
eccentricity: f64,
inc_deg: f64,
raan_deg: f64,
argp_deg: f64,
mean_anom_deg: f64,
) -> LunarState {
let e = eccentricity;
let a = sma_m;
let m = mean_anom_deg.to_radians();
let mut ea = m;
if e != 0.0 {
for _ in 0..60 {
let d = (ea - e * ea.psin() - m) / (1.0 - e * ea.pcos());
ea -= d;
if d.abs() < 1e-14 {
break;
}
}
}
let r = a * (1.0 - e * ea.pcos());
let nu =
2.0 * ((1.0 + e).sqrt() * (ea * 0.5).psin()).patan2((1.0 - e).sqrt() * (ea * 0.5).pcos());
let p = a * (1.0 - e * e);
let (snu, cnu) = nu.psin_cos();
let r_pf: Vec3 = [r * cnu, r * snu, 0.0];
let sqrt_mu_p = (MOON_MU_M3_S2 / p).sqrt();
let v_pf: Vec3 = [-sqrt_mu_p * snu, sqrt_mu_p * (e + cnu), 0.0];
let argp = argp_deg.to_radians();
let inc = inc_deg.to_radians();
let raan = raan_deg.to_radians();
let to_mci = |vpf: Vec3| rotz(rotx(rotz(vpf, argp), inc), raan);
LunarState {
r: to_mci(r_pf),
v: to_mci(v_pf),
}
}
pub fn osculating_sma(state: &LunarState) -> f64 {
let rn = norm(state.r);
let v2 = dot(state.v, state.v);
1.0 / (2.0 / rn - v2 / MOON_MU_M3_S2)
}
pub fn osculating_raan(state: &LunarState) -> f64 {
let h = cross(state.r, state.v);
h[0].patan2(-h[1])
}
pub fn osculating_argp(state: &LunarState) -> f64 {
let r = state.r;
let v = state.v;
let h = cross(r, v);
let n = [-h[1], h[0], 0.0];
let rn = norm(r);
let e_vec = sub(scale(cross(v, h), 1.0 / MOON_MU_M3_S2), scale(r, 1.0 / rn));
let nn = norm(n);
let en = norm(e_vec);
if nn == 0.0 || en == 0.0 {
return 0.0;
}
let mut w = (dot(n, e_vec) / (nn * en)).clamp(-1.0, 1.0).pacos();
if e_vec[2] < 0.0 {
w = std::f64::consts::TAU - w;
}
w
}
pub fn moon_two_body_accel(r: Vec3) -> Vec3 {
let rn = norm(r);
scale(r, -MOON_MU_M3_S2 / (rn * rn * rn))
}
pub fn moon_j2_accel(r: Vec3) -> Vec3 {
let rn = norm(r);
let r2 = rn * rn;
let zr2 = 5.0 * r[2] * r[2] / r2;
let c = -1.5 * MOON_J2 * MOON_MU_M3_S2 * MOON_REF_RADIUS_M * MOON_REF_RADIUS_M / rn.ppowi(5);
[
c * r[0] * (1.0 - zr2),
c * r[1] * (1.0 - zr2),
c * r[2] * (3.0 - zr2),
]
}
pub fn moon_c22_potential_bodyfixed(r_bf: Vec3) -> f64 {
let rn = norm(r_bf);
let k = 3.0 * MOON_MU_M3_S2 * MOON_REF_RADIUS_M * MOON_REF_RADIUS_M * MOON_C22;
k * (r_bf[0] * r_bf[0] - r_bf[1] * r_bf[1]) / rn.ppowi(5)
}
pub fn moon_c22_accel_bodyfixed(r_bf: Vec3) -> Vec3 {
let rn = norm(r_bf);
let r2 = rn * rn;
let k = 3.0 * MOON_MU_M3_S2 * MOON_REF_RADIUS_M * MOON_REF_RADIUS_M * MOON_C22 / rn.ppowi(5);
let dxy = r_bf[0] * r_bf[0] - r_bf[1] * r_bf[1];
[
k * (2.0 * r_bf[0] - 5.0 * r_bf[0] * dxy / r2),
k * (-2.0 * r_bf[1] - 5.0 * r_bf[1] * dxy / r2),
k * (-5.0 * r_bf[2] * dxy / r2),
]
}
pub fn moon_c22_accel_mci(r_mci: Vec3, seconds: f64) -> Vec3 {
let r_bf = mci_to_mcmf(r_mci, seconds);
let a_bf = moon_c22_accel_bodyfixed(r_bf);
mcmf_to_mci(a_bf, seconds)
}
pub fn earth_position_mci(t_tt_jc: f64) -> Vec3 {
scale(moon_position(t_tt_jc), -1.0)
}
pub fn sun_position_mci(t_tt_jc: f64) -> Vec3 {
sub(sun_position(t_tt_jc), moon_position(t_tt_jc))
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LunarPerturbations {
pub j2: bool,
pub c22: bool,
pub earth: bool,
pub sun: bool,
pub epoch_tt_jc: f64,
}
impl Default for LunarPerturbations {
fn default() -> Self {
Self::elfo_full()
}
}
impl LunarPerturbations {
pub fn two_body() -> Self {
Self {
j2: false,
c22: false,
earth: false,
sun: false,
epoch_tt_jc: 0.0,
}
}
pub fn j2_only() -> Self {
Self {
j2: true,
..Self::two_body()
}
}
pub fn elfo_full() -> Self {
Self {
j2: true,
c22: true,
earth: true,
sun: true,
epoch_tt_jc: 0.0,
}
}
pub fn with_epoch(mut self, epoch_tt_jc: f64) -> Self {
self.epoch_tt_jc = epoch_tt_jc;
self
}
pub fn with_c22(mut self, on: bool) -> Self {
self.c22 = on;
self
}
pub fn with_earth(mut self, on: bool) -> Self {
self.earth = on;
self
}
pub fn with_sun(mut self, on: bool) -> Self {
self.sun = on;
self
}
pub fn accel(&self, t: f64, r: Vec3) -> Vec3 {
let mut a = moon_two_body_accel(r);
if self.j2 {
a = add(a, moon_j2_accel(r));
}
if self.c22 {
a = add(a, moon_c22_accel_mci(r, t));
}
if self.earth || self.sun {
let t_jc = self.epoch_tt_jc + t / JULIAN_CENTURY_S;
if self.earth {
a = add(a, third_body_accel(r, earth_position_mci(t_jc), MU_EARTH));
}
if self.sun {
a = add(a, third_body_accel(r, sun_position_mci(t_jc), MU_SUN));
}
}
a
}
fn rhs(&self) -> impl Fn(f64, &[f64]) -> Vec<f64> + '_ {
move |t: f64, y: &[f64]| {
let a = self.accel(t, [y[0], y[1], y[2]]);
vec![y[3], y[4], y[5], a[0], a[1], a[2]]
}
}
}
pub fn default_tolerance() -> Tolerance {
Tolerance {
rtol: 1e-12,
atol: 1e-6,
h_min: 1e-4,
h_max: 120.0,
}
}
pub fn propagate(
state0: &LunarState,
t_end: f64,
model: &LunarPerturbations,
tol: &Tolerance,
) -> LunarState {
if !(t_end.is_finite() && t_end > 0.0) {
return *state0;
}
let f = model.rhs();
let y0 = vec![
state0.r[0],
state0.r[1],
state0.r[2],
state0.v[0],
state0.v[1],
state0.v[2],
];
let h0 = (t_end / 1000.0).clamp(1e-3, tol.h_max);
let sol = integrate(&f, 0.0, &y0, t_end, h0, tol);
LunarState {
r: [sol.y[0], sol.y[1], sol.y[2]],
v: [sol.y[3], sol.y[4], sol.y[5]],
}
}
pub fn propagate_history(
state0: &LunarState,
t_end: f64,
step_s: f64,
model: &LunarPerturbations,
) -> Vec<(f64, LunarState)> {
let f = model.rhs();
let mut y = vec![
state0.r[0],
state0.r[1],
state0.r[2],
state0.v[0],
state0.v[1],
state0.v[2],
];
let mut t = 0.0;
let mut out = vec![(0.0, *state0)];
if !(step_s.is_finite() && step_s > 0.0) {
return out;
}
let n_steps = (((t_end - 1e-9) / step_s).ceil().max(0.0) as usize).saturating_add(2);
for _ in 0..n_steps {
if t >= t_end - 1e-9 {
break;
}
y = rk4_step(&f, t, &y, step_s);
t += step_s;
out.push((
t,
LunarState {
r: [y[0], y[1], y[2]],
v: [y[3], y[4], y[5]],
},
));
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct J2SecularRates {
pub raan: f64,
pub arg_perilune: f64,
}
pub fn j2_secular_rates(a: f64, e: f64, i_rad: f64) -> J2SecularRates {
let n = mean_motion(a);
let p = a * (1.0 - e * e);
let factor = n * MOON_J2 * (MOON_REF_RADIUS_M / p).ppowi(2);
let ci = i_rad.pcos();
J2SecularRates {
raan: -1.5 * factor * ci,
arg_perilune: 0.75 * factor * (5.0 * ci * ci - 1.0),
}
}
#[derive(Clone, Debug)]
pub struct PerturbedConstellation {
pub states0: Vec<LunarState>,
pub model: LunarPerturbations,
pub tol: Tolerance,
}
impl PerturbedConstellation {
pub fn new(states0: Vec<LunarState>, model: LunarPerturbations, tol: Tolerance) -> Self {
Self {
states0,
model,
tol,
}
}
pub fn from_lcns(n: usize, model: LunarPerturbations) -> Self {
let base = crate::lunar_service::LunarConstellation::illustrative_lcns(n);
let states0 = base
.sats
.iter()
.map(|s| {
elements_to_state(
s.sma_m,
s.eccentricity,
s.inc_deg,
s.raan_deg,
s.argp_deg,
s.mean_anom_deg,
)
})
.collect();
Self::new(states0, model, default_tolerance())
}
pub fn n_sats(&self) -> usize {
self.states0.len()
}
pub fn positions_mci(&self, t_s: f64) -> Vec<Vec3> {
self.states0
.iter()
.map(|s| propagate(s, t_s, &self.model, &self.tol).r)
.collect()
}
pub fn positions_mcmf(&self, t_s: f64) -> Vec<Vec3> {
self.states0
.iter()
.map(|s| mci_to_mcmf(propagate(s, t_s, &self.model, &self.tol).r, t_s))
.collect()
}
}
pub const GM_MOON_DE440: f64 = 4.902_800_118_457_549e12;
pub const GM_EARTH_DE440: f64 = 3.986_004_355_070_226e14;
pub const GM_SUN_DE440: f64 = 1.327_124_400_412_793_9e20;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EphemerisForceModel {
pub gm_moon: f64,
pub gm_earth: f64,
pub gm_sun: f64,
pub j2: f64,
pub c22: f64,
pub r_ref: f64,
pub earth: bool,
pub sun: bool,
}
impl EphemerisForceModel {
pub fn de440() -> Self {
Self {
gm_moon: GM_MOON_DE440,
gm_earth: GM_EARTH_DE440,
gm_sun: GM_SUN_DE440,
j2: MOON_J2,
c22: MOON_C22,
r_ref: MOON_REF_RADIUS_M,
earth: true,
sun: true,
}
}
pub fn accel(&self, r: Vec3, rot: &[[f64; 3]; 3], earth: Vec3, sun: Vec3) -> Vec3 {
let rn = norm(r);
let mut a = scale(r, -self.gm_moon / (rn * rn * rn));
let rb = crate::naif_kernel::mat_vec(rot, r);
let r2 = rn * rn;
let r5 = r2 * r2 * rn;
let mr2 = self.gm_moon * self.r_ref * self.r_ref;
let zr2 = 5.0 * rb[2] * rb[2] / r2;
let cj = -1.5 * self.j2 * mr2 / r5;
let k = 3.0 * mr2 * self.c22 / r5;
let dxy = rb[0] * rb[0] - rb[1] * rb[1];
let ab = [
cj * rb[0] * (1.0 - zr2) + k * (2.0 * rb[0] - 5.0 * rb[0] * dxy / r2),
cj * rb[1] * (1.0 - zr2) + k * (-2.0 * rb[1] - 5.0 * rb[1] * dxy / r2),
cj * rb[2] * (3.0 - zr2) + k * (-5.0 * rb[2] * dxy / r2),
];
a = add(
a,
[
rot[0][0] * ab[0] + rot[1][0] * ab[1] + rot[2][0] * ab[2],
rot[0][1] * ab[0] + rot[1][1] * ab[1] + rot[2][1] * ab[2],
rot[0][2] * ab[0] + rot[1][2] * ab[1] + rot[2][2] * ab[2],
],
);
let third = |a: Vec3, rb: Vec3, gm: f64| {
let d = sub(rb, r);
let dn = norm(d);
let bn = norm(rb);
add(
a,
sub(
scale(d, gm / (dn * dn * dn)),
scale(rb, gm / (bn * bn * bn)),
),
)
};
if self.earth {
a = third(a, earth, self.gm_earth);
}
if self.sun {
a = third(a, sun, self.gm_sun);
}
a
}
}
#[derive(Clone, Copy, Debug)]
pub struct SharedGeometry {
pub rot: [[f64; 3]; 3],
pub earth: Vec3,
pub sun: Vec3,
}
pub fn shared_geometry(
spk: &crate::naif_kernel::SpkKernel,
et_hi: f64,
et_lo: f64,
) -> Result<SharedGeometry, String> {
let earth = spk.state(399, 301, et_hi, et_lo)?[0];
let sun = spk.state(10, 301, et_hi, et_lo)?[0];
let jd_tdb = crate::timescales::JD_J2000 + (et_hi + et_lo) / SECONDS_PER_DAY;
Ok(SharedGeometry {
rot: crate::lunar_frame::icrf_to_iau_moon(jd_tdb),
earth,
sun,
})
}
pub fn op_frame(
spk: &crate::naif_kernel::SpkKernel,
et_hi: f64,
et_lo: f64,
) -> Result<[[f64; 3]; 3], String> {
let e = spk.state(399, 301, et_hi, et_lo)?;
let z = unit(cross(e[0], e[1]));
let jd_tdb = crate::timescales::JD_J2000 + (et_hi + et_lo) / SECONDS_PER_DAY;
let (ra, dec) = crate::lunar_frame::lunar_pole_ra_dec(jd_tdb);
let p = [dec.pcos() * ra.pcos(), dec.pcos() * ra.psin(), dec.psin()];
let x = unit(cross(p, z));
let y = cross(z, x);
Ok([x, y, z])
}
#[allow(clippy::too_many_arguments)]
pub fn elements_in_frame_to_state(
gm: f64,
frame: &[[f64; 3]; 3],
sma_m: f64,
eccentricity: f64,
inc_deg: f64,
raan_deg: f64,
argp_deg: f64,
mean_anom_deg: f64,
) -> LunarState {
let e = eccentricity;
let m = mean_anom_deg.to_radians();
let mut ea = m;
for _ in 0..60 {
let d = (ea - e * ea.psin() - m) / (1.0 - e * ea.pcos());
ea -= d;
if d.abs() < 1e-15 {
break;
}
}
let nu =
2.0 * ((1.0 + e).sqrt() * (ea * 0.5).psin()).patan2((1.0 - e).sqrt() * (ea * 0.5).pcos());
let p = sma_m * (1.0 - e * e);
let r = p / (1.0 + e * nu.pcos());
let (snu, cnu) = nu.psin_cos();
let r_pf: Vec3 = [r * cnu, r * snu, 0.0];
let k = (gm / p).sqrt();
let v_pf: Vec3 = [-k * snu, k * (e + cnu), 0.0];
let (argp, inc, raan) = (
argp_deg.to_radians(),
inc_deg.to_radians(),
raan_deg.to_radians(),
);
let to_frame = |v: Vec3| rotz(rotx(rotz(v, argp), inc), raan);
let to_icrf = |v: Vec3| {
[
frame[0][0] * v[0] + frame[1][0] * v[1] + frame[2][0] * v[2],
frame[0][1] * v[0] + frame[1][1] * v[1] + frame[2][1] * v[2],
frame[0][2] * v[0] + frame[1][2] * v[1] + frame[2][2] * v[2],
]
};
LunarState {
r: to_icrf(to_frame(r_pf)),
v: to_icrf(to_frame(v_pf)),
}
}
#[derive(Clone, Debug)]
pub struct TabulatedConstellation {
pub epoch_et: (f64, f64),
pub output_step_s: f64,
pub positions: Vec<Vec<Vec3>>,
}
pub fn propagate_tabulated(
spk: &crate::naif_kernel::SpkKernel,
model: &EphemerisForceModel,
states0: &[LunarState],
epoch_et: (f64, f64),
duration_s: f64,
step_s: f64,
output_step_s: f64,
) -> Result<TabulatedConstellation, String> {
let per_out = (output_step_s / step_s).round() as usize;
if per_out == 0 || (per_out as f64 * step_s - output_step_s).abs() > 1e-9 {
return Err("the integration step must divide the output step".into());
}
let n_out = (duration_s / output_step_s).round() as usize;
let mut y: Vec<[f64; 6]> = states0
.iter()
.map(|s| [s.r[0], s.r[1], s.r[2], s.v[0], s.v[1], s.v[2]])
.collect();
let mut positions = Vec::with_capacity(n_out + 1);
positions.push(y.iter().map(|s| [s[0], s[1], s[2]]).collect::<Vec<Vec3>>());
let deriv = |g: &SharedGeometry, s: &[f64; 6]| -> [f64; 6] {
let a = model.accel([s[0], s[1], s[2]], &g.rot, g.earth, g.sun);
[s[3], s[4], s[5], a[0], a[1], a[2]]
};
let axpy = |s: &[f64; 6], k: &[f64; 6], h: f64| -> [f64; 6] {
let mut o = *s;
for i in 0..6 {
o[i] += h * k[i];
}
o
};
for kout in 0..n_out {
for j in 0..per_out {
let t0 = (kout * per_out + j) as f64 * step_s;
let g0 = shared_geometry(spk, epoch_et.0, epoch_et.1 + t0)?;
let gm = shared_geometry(spk, epoch_et.0, epoch_et.1 + t0 + 0.5 * step_s)?;
let g1 = shared_geometry(spk, epoch_et.0, epoch_et.1 + t0 + step_s)?;
for s in y.iter_mut() {
let k1 = deriv(&g0, s);
let k2 = deriv(&gm, &axpy(s, &k1, 0.5 * step_s));
let k3 = deriv(&gm, &axpy(s, &k2, 0.5 * step_s));
let k4 = deriv(&g1, &axpy(s, &k3, step_s));
for i in 0..6 {
s[i] += step_s / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]);
}
}
}
positions.push(y.iter().map(|s| [s[0], s[1], s[2]]).collect());
}
Ok(TabulatedConstellation {
epoch_et,
output_step_s,
positions,
})
}
impl TabulatedConstellation {
pub fn positions_icrf(&self, k: usize) -> &[Vec3] {
&self.positions[k]
}
}
impl crate::lunar_service::PositionsMcmf for TabulatedConstellation {
fn positions_mcmf(&self, t_s: f64) -> Vec<Vec3> {
let k = (t_s / self.output_step_s).round() as usize;
assert!(
(k as f64 * self.output_step_s - t_s).abs() < 1e-6 && k < self.positions.len(),
"t = {t_s} s is not a tabulated sample"
);
let jd_tdb = crate::timescales::JD_J2000
+ (self.epoch_et.0 + self.epoch_et.1 + t_s) / SECONDS_PER_DAY;
let rot = crate::lunar_frame::icrf_to_iau_moon(jd_tdb);
self.positions[k]
.iter()
.map(|&r| crate::naif_kernel::mat_vec(&rot, r))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lunar_service::LunarSat;
use crate::propagator::secular_slope;
use std::f64::consts::TAU;
const A_TEST: f64 = R_MOON_M + 3_000_000.0;
const E_TEST: f64 = 0.1;
const I_TEST_DEG: f64 = 45.0;
const RAAN_TEST_DEG: f64 = 30.0;
const ARGP_TEST_DEG: f64 = 40.0;
fn test_state() -> LunarState {
elements_to_state(
A_TEST,
E_TEST,
I_TEST_DEG,
RAAN_TEST_DEG,
ARGP_TEST_DEG,
10.0,
)
}
#[test]
fn elements_to_state_position_matches_analytic_lunarsat() {
for &(e, argp, m0) in &[(0.0, 0.0, 0.0), (0.1, 40.0, 10.0), (0.6, 90.0, 123.0)] {
let st = elements_to_state(A_TEST, e, I_TEST_DEG, RAAN_TEST_DEG, argp, m0);
let sat = LunarSat {
sma_m: A_TEST,
eccentricity: e,
inc_deg: I_TEST_DEG,
raan_deg: RAAN_TEST_DEG,
argp_deg: argp,
mean_anom_deg: m0,
};
let p = sat.position_mci(0.0);
let d = norm(sub(st.r, p));
assert!(d < 1e-6, "IC position vs analytic LunarSat: {d} m (e={e})");
}
}
#[test]
fn two_body_limit_reproduces_analytic_kepler_to_submetre() {
let e = 0.3;
let argp = 25.0;
let m0 = 15.0;
let st = elements_to_state(A_TEST, e, I_TEST_DEG, RAAN_TEST_DEG, argp, m0);
let sat = LunarSat {
sma_m: A_TEST,
eccentricity: e,
inc_deg: I_TEST_DEG,
raan_deg: RAAN_TEST_DEG,
argp_deg: argp,
mean_anom_deg: m0,
};
let model = LunarPerturbations::two_body();
let tol = default_tolerance();
let period = TAU / mean_motion(A_TEST);
for k in 1..=4 {
let t = k as f64 * period * 0.9; let got = propagate(&st, t, &model, &tol).r;
let truth = sat.position_mci(t);
let d = norm(sub(got, truth));
assert!(
d < 1.0,
"two-body limit at t={t:.0}s: {d:.4} m vs analytic Kepler"
);
}
}
#[test]
fn j2_secular_raan_and_argp_match_closed_form() {
let st = test_state();
let model = LunarPerturbations::j2_only();
let n = mean_motion(A_TEST);
let period = TAU / n;
let n_orbits = 12.0;
let t_end = n_orbits * period;
let step = period / 240.0;
let hist = propagate_history(&st, t_end, step, &model);
let raan_series: Vec<(f64, f64)> =
hist.iter().map(|(t, s)| (*t, osculating_raan(s))).collect();
let argp_series: Vec<(f64, f64)> =
hist.iter().map(|(t, s)| (*t, osculating_argp(s))).collect();
let raan_rate = secular_slope(&raan_series);
let argp_rate = secular_slope(&argp_series);
let oracle = j2_secular_rates(A_TEST, E_TEST, I_TEST_DEG.to_radians());
let raan_rel = (raan_rate - oracle.raan).abs() / oracle.raan.abs();
let argp_rel = (argp_rate - oracle.arg_perilune).abs() / oracle.arg_perilune.abs();
assert!(
raan_rel < 0.03,
"Ω̇: propagated {raan_rate:.6e} vs oracle {:.6e} rad/s ({:.2}%)",
oracle.raan,
raan_rel * 100.0
);
assert!(
argp_rel < 0.03,
"ω̇: propagated {argp_rate:.6e} vs oracle {:.6e} rad/s ({:.2}%)",
oracle.arg_perilune,
argp_rel * 100.0
);
assert!(raan_rate < 0.0, "Ω̇ must be retrograde: {raan_rate:.3e}");
assert!(
argp_rate > 0.0,
"ω̇ must be prograde below i_crit: {argp_rate:.3e}"
);
}
#[test]
fn c22_accel_is_the_exact_gradient_of_its_potential() {
let r = [2.5e6, -1.1e6, 8.0e5];
let a = moon_c22_accel_bodyfixed(r);
let h = 1.0; for k in 0..3 {
let mut rp = r;
let mut rm = r;
rp[k] += h;
rm[k] -= h;
let fd =
(moon_c22_potential_bodyfixed(rp) - moon_c22_potential_bodyfixed(rm)) / (2.0 * h);
let rel = (a[k] - fd).abs() / fd.abs().max(1e-30);
assert!(rel < 1e-5, "C22 ∇U comp {k}: analytic {} vs FD {fd}", a[k]);
}
}
#[test]
fn j2_only_conserves_semi_major_axis_and_stays_bounded() {
let st = test_state();
let model = LunarPerturbations::j2_only();
let period = TAU / mean_motion(A_TEST);
let hist = propagate_history(&st, 8.0 * period, period / 200.0, &model);
let a0 = osculating_sma(&st);
for (_, s) in &hist {
let a = osculating_sma(s);
let rel = (a - a0).abs() / a0;
assert!(rel < 5e-3, "J2 must not change a secularly: rel {rel:.2e}");
let rn = norm(s.r);
assert!(
rn.is_finite() && rn > R_MOON_M,
"orbit must stay above the surface: {rn:.0} m"
);
}
}
#[test]
fn full_elfo_stays_bounded_over_days() {
let st = elements_to_state(R_MOON_M + 8_000_000.0, 0.6, 57.7, 0.0, 90.0, 0.0);
let model = LunarPerturbations::elfo_full();
let apolune = (R_MOON_M + 8_000_000.0) * (1.0 + 0.6);
let hist = propagate_history(&st, 2.0 * SECONDS_PER_DAY, 300.0, &model);
for (_, s) in &hist {
let rn = norm(s.r);
assert!(
rn.is_finite() && rn > R_MOON_M && rn < 3.0 * apolune,
"full ELFO radius left the bounded band: {rn:.0} m"
);
}
}
#[test]
fn perturbed_constellation_moves_relative_to_two_body_but_stays_sane() {
let n = 4;
let two_body = PerturbedConstellation::from_lcns(n, LunarPerturbations::two_body());
let perturbed = PerturbedConstellation::from_lcns(n, LunarPerturbations::elfo_full());
assert_eq!(perturbed.n_sats(), n);
let p0_tb = two_body.positions_mcmf(0.0);
let p0_pt = perturbed.positions_mcmf(0.0);
for (a, b) in p0_tb.iter().zip(p0_pt.iter()) {
assert!(norm(sub(*a, *b)) < 1e-6, "epoch positions must coincide");
}
let t = 0.5 * SECONDS_PER_DAY;
let tb = two_body.positions_mcmf(t);
let pt = perturbed.positions_mcmf(t);
let mut max_shift = 0.0_f64;
for (a, b) in tb.iter().zip(pt.iter()) {
let shift = norm(sub(*a, *b));
max_shift = max_shift.max(shift);
let rn = norm(*b);
assert!(
rn.is_finite() && rn > R_MOON_M,
"perturbed sat left the body: {rn:.0} m"
);
}
assert!(
max_shift > 1_000.0,
"perturbed geometry barely moved: {max_shift:.1} m"
);
}
#[test]
fn propagation_is_deterministic() {
let st = test_state();
let model = LunarPerturbations::elfo_full();
let tol = default_tolerance();
let a = propagate(&st, 12_345.0, &model, &tol);
let b = propagate(&st, 12_345.0, &model, &tol);
assert_eq!(
a, b,
"no RNG / wall-clock: identical inputs give identical output"
);
}
}