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::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 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.sin_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.sin_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.sin() - m) / (1.0 - e * ea.cos());
ea -= d;
if d.abs() < 1e-14 {
break;
}
}
}
let r = a * (1.0 - e * ea.cos());
let nu = 2.0 * ((1.0 + e).sqrt() * (ea * 0.5).sin()).atan2((1.0 - e).sqrt() * (ea * 0.5).cos());
let p = a * (1.0 - e * e);
let (snu, cnu) = nu.sin_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].atan2(-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).acos();
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.powi(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.powi(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.powi(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).powi(2);
let ci = i_rad.cos();
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()
}
}
#[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"
);
}
}