pub const MU_EARTH: f64 = 3.986_004_418e14;
pub const RE_EARTH: f64 = 6_378_137.0;
pub const J2: f64 = 1.082_626_68e-3;
pub const J3: f64 = -2.5327e-6;
pub const J4: f64 = -1.6196e-6;
pub const J5: f64 = -2.2730e-7;
pub const J6: f64 = 5.4068e-7;
pub const EARTH_ZONALS_J2_J6: [f64; 5] = [J2, J3, J4, J5, J6];
use crate::ephem::AU_M;
type Vec3 = [f64; 3];
fn norm(r: Vec3) -> f64 {
(r[0] * r[0] + r[1] * r[1] + r[2] * r[2]).sqrt()
}
pub fn two_body_accel(r: Vec3) -> Vec3 {
let rn = norm(r);
let k = -MU_EARTH / (rn * rn * rn);
[k * r[0], k * r[1], k * r[2]]
}
pub fn 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 * J2 * MU_EARTH * RE_EARTH * RE_EARTH / rn.powi(5);
[
c * r[0] * (1.0 - zr2),
c * r[1] * (1.0 - zr2),
c * r[2] * (3.0 - zr2),
]
}
pub fn gravity_accel(r: Vec3) -> Vec3 {
let a = two_body_accel(r);
let b = j2_accel(r);
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn legendre(s: f64, deg: usize) -> (Vec<f64>, Vec<f64>) {
let mut p = vec![0.0; deg + 1];
let mut dp = vec![0.0; deg + 1];
p[0] = 1.0;
if deg >= 1 {
p[1] = s;
dp[1] = 1.0;
}
for n in 2..=deg {
let nf = n as f64;
p[n] = ((2.0 * nf - 1.0) * s * p[n - 1] - (nf - 1.0) * p[n - 2]) / nf;
dp[n] = s * dp[n - 1] + nf * p[n - 1];
}
(p, dp)
}
pub fn zonal_potential(r: Vec3, jn: &[f64]) -> f64 {
let rn = norm(r);
let s = r[2] / rn;
let (p, _) = legendre(s, jn.len() + 1);
let mut sum = 0.0;
for (i, &j) in jn.iter().enumerate() {
let n = i + 2;
sum += j * (RE_EARTH / rn).powi(n as i32) * p[n];
}
-MU_EARTH / rn * sum
}
pub fn zonal_accel(r: Vec3, jn: &[f64]) -> Vec3 {
let rn = norm(r);
let s = r[2] / rn;
let (p, dp) = legendre(s, jn.len() + 1);
let dsdx = [
-r[2] * r[0] / rn.powi(3),
-r[2] * r[1] / rn.powi(3),
(rn * rn - r[2] * r[2]) / rn.powi(3),
];
let mut a = [0.0; 3];
for (i, &j) in jn.iter().enumerate() {
let n = i + 2;
let ni = n as i32;
let coef = -MU_EARTH * j * RE_EARTH.powi(ni);
let t1 = -(n as f64 + 1.0) * rn.powi(-(ni + 3));
let t2 = rn.powi(-(ni + 1)) * dp[n];
for k in 0..3 {
a[k] += coef * (t1 * r[k] * p[n] + t2 * dsdx[k]);
}
}
a
}
pub const MU_SUN: f64 = 1.327_124_400_18e20;
pub const MU_MOON: f64 = 4.902_800_066e12;
pub fn third_body_accel(r: Vec3, s: Vec3, mu3: f64) -> Vec3 {
let d = [s[0] - r[0], s[1] - r[1], s[2] - r[2]];
let dn = norm(d);
let sn = norm(s);
let kd = mu3 / (dn * dn * dn);
let ks = mu3 / (sn * sn * sn);
[
kd * d[0] - ks * s[0],
kd * d[1] - ks * s[1],
kd * d[2] - ks * s[2],
]
}
pub fn third_body_potential(r: Vec3, s: Vec3, mu3: f64) -> f64 {
let d = [s[0] - r[0], s[1] - r[1], s[2] - r[2]];
let dn = norm(d);
let sn = norm(s);
let rs = r[0] * s[0] + r[1] * s[1] + r[2] * s[2];
mu3 * (1.0 / dn - rs / (sn * sn * sn))
}
pub const SOLAR_IRRADIANCE_AU: f64 = 1361.0;
const SPEED_OF_LIGHT: f64 = 299_792_458.0;
pub const SRP_PRESSURE_AU: f64 = SOLAR_IRRADIANCE_AU / SPEED_OF_LIGHT;
pub fn cylindrical_shadow(r: Vec3, s: Vec3) -> f64 {
let sn = norm(s);
let r_par = (r[0] * s[0] + r[1] * s[1] + r[2] * s[2]) / sn;
if r_par >= 0.0 {
return 1.0; }
let rn2 = r[0] * r[0] + r[1] * r[1] + r[2] * r[2];
let r_perp = (rn2 - r_par * r_par).max(0.0).sqrt();
if r_perp < RE_EARTH {
0.0
} else {
1.0
}
}
pub const SOLAR_RADIUS: f64 = 6.957e8;
pub fn conical_shadow(r: Vec3, s: Vec3) -> f64 {
let d = [s[0] - r[0], s[1] - r[1], s[2] - r[2]]; let d_sun = norm(d);
let r_n = norm(r);
if d_sun == 0.0 || r_n == 0.0 {
return 1.0;
}
let a = (SOLAR_RADIUS / d_sun).clamp(-1.0, 1.0).asin(); let b = (RE_EARTH / r_n).clamp(-1.0, 1.0).asin(); let dot = d[0] * (-r[0]) + d[1] * (-r[1]) + d[2] * (-r[2]);
let c = (dot / (d_sun * r_n)).clamp(-1.0, 1.0).acos();
if c >= a + b {
return 1.0; }
if c <= b - a {
return 0.0; }
if c <= a - b {
return 1.0 - (b * b) / (a * a); }
let x = ((c * c + a * a - b * b) / (2.0 * c * a)).clamp(-1.0, 1.0);
let y = ((c * c + b * b - a * a) / (2.0 * c * b)).clamp(-1.0, 1.0);
let tri = ((-c + a + b) * (c + a - b) * (c - a + b) * (c + a + b)).max(0.0);
let overlap = a * a * x.acos() + b * b * y.acos() - 0.5 * tri.sqrt();
(1.0 - overlap / (std::f64::consts::PI * a * a)).clamp(0.0, 1.0)
}
pub fn srp_accel(r: Vec3, s: Vec3, cr: f64, area_over_mass: f64) -> Vec3 {
let nu = conical_shadow(r, s);
if nu == 0.0 {
return [0.0, 0.0, 0.0];
}
let d = [r[0] - s[0], r[1] - s[1], r[2] - s[2]];
let dn = norm(d);
let inv = 1.0 / dn;
let scale = nu * SRP_PRESSURE_AU * cr * area_over_mass * AU_M * AU_M * inv * inv * inv;
[scale * d[0], scale * d[1], scale * d[2]]
}
pub const EARTH_ROTATION_RATE: f64 = 7.292_115_146_7e-5;
pub fn atmospheric_density(altitude_m: f64) -> f64 {
const BANDS: [(f64, f64, f64); 28] = [
(0.0, 1.225, 7.249),
(25.0, 3.899e-2, 6.349),
(30.0, 1.774e-2, 6.682),
(40.0, 3.972e-3, 7.554),
(50.0, 1.057e-3, 8.382),
(60.0, 3.206e-4, 7.714),
(70.0, 8.770e-5, 6.549),
(80.0, 1.905e-5, 5.799),
(90.0, 3.396e-6, 5.382),
(100.0, 5.297e-7, 5.877),
(110.0, 9.661e-8, 7.263),
(120.0, 2.438e-8, 9.473),
(130.0, 8.484e-9, 12.636),
(140.0, 3.845e-9, 16.149),
(150.0, 2.070e-9, 22.523),
(180.0, 5.464e-10, 29.740),
(200.0, 2.789e-10, 37.105),
(250.0, 7.248e-11, 45.546),
(300.0, 2.418e-11, 53.628),
(350.0, 9.518e-12, 53.298),
(400.0, 3.725e-12, 58.515),
(450.0, 1.585e-12, 60.828),
(500.0, 6.967e-13, 63.822),
(600.0, 1.454e-13, 71.835),
(700.0, 3.614e-14, 88.667),
(800.0, 1.170e-14, 124.64),
(900.0, 5.245e-15, 181.05),
(1000.0, 3.019e-15, 268.00),
];
let h_km = (altitude_m / 1000.0).max(0.0);
let mut i = 0;
while i + 1 < BANDS.len() && BANDS[i + 1].0 <= h_km {
i += 1;
}
let (h0, rho0, scale) = BANDS[i];
rho0 * (-(h_km - h0) / scale).exp()
}
pub fn drag_accel(r: Vec3, v: Vec3, cd_area_over_mass: f64) -> Vec3 {
let rho = atmospheric_density(norm(r) - RE_EARTH);
let w = EARTH_ROTATION_RATE;
let v_rel = [v[0] + w * r[1], v[1] - w * r[0], v[2]];
let coef = -0.5 * rho * cd_area_over_mass * norm(v_rel);
[coef * v_rel[0], coef * v_rel[1], coef * v_rel[2]]
}
pub fn relativistic_accel(r: Vec3, v: Vec3) -> Vec3 {
let rn = norm(r);
let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
let v2 = v[0] * v[0] + v[1] * v[1] + v[2] * v[2];
let rv = r[0] * v[0] + r[1] * v[1] + r[2] * v[2];
let pre = MU_EARTH / (c2 * rn * rn * rn);
let kr = 4.0 * MU_EARTH / rn - v2;
let kv = 4.0 * rv;
[
pre * (kr * r[0] + kv * v[0]),
pre * (kr * r[1] + kv * v[1]),
pre * (kr * r[2] + kv * v[2]),
]
}
pub const EARTH_ANGULAR_MOMENTUM_SPECIFIC: f64 = 9.81e8;
pub fn lense_thirring_accel(r: Vec3, v: Vec3) -> Vec3 {
let rn = norm(r);
let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
let j = [0.0, 0.0, EARTH_ANGULAR_MOMENTUM_SPECIFIC];
let pre = 2.0 * MU_EARTH / (c2 * rn * rn * rn);
let rxv = [
r[1] * v[2] - r[2] * v[1],
r[2] * v[0] - r[0] * v[2],
r[0] * v[1] - r[1] * v[0],
];
let vxj = [
v[1] * j[2] - v[2] * j[1],
v[2] * j[0] - v[0] * j[2],
v[0] * j[1] - v[1] * j[0],
];
let rdotj = r[0] * j[0] + r[1] * j[1] + r[2] * j[2];
let f = 3.0 / (rn * rn);
[
pre * (f * rxv[0] * rdotj + vxj[0]),
pre * (f * rxv[1] * rdotj + vxj[1]),
pre * (f * rxv[2] * rdotj + vxj[2]),
]
}
pub fn mean_motion(a: f64) -> f64 {
(MU_EARTH / (a * a * a)).sqrt()
}
#[derive(Clone, Copy, Debug)]
pub struct SecularRates {
pub raan: f64,
pub arg_perigee: f64,
pub mean_anomaly: f64,
}
pub fn j2_secular_rates(a: f64, e: f64, i_rad: f64) -> SecularRates {
let n = mean_motion(a);
let p = a * (1.0 - e * e);
let factor = n * J2 * (RE_EARTH / p).powi(2);
let (si, ci) = i_rad.sin_cos();
let sin2 = si * si;
SecularRates {
raan: -1.5 * factor * ci,
arg_perigee: 1.5 * factor * (2.0 - 2.5 * sin2),
mean_anomaly: 1.5 * factor * (1.0 - e * e).sqrt() * (1.0 - 1.5 * sin2),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn two_body_acceleration_is_mu_over_r_squared() {
let r = [7.0e6, 0.0, 0.0];
let a = two_body_accel(r);
let expect = MU_EARTH / (7.0e6 * 7.0e6); assert!((a[0] + expect).abs() / expect < 1e-12, "ax = {}", a[0]);
assert!(a[1].abs() < 1e-12 && a[2].abs() < 1e-12);
assert!((expect - 8.1347).abs() < 1e-3, "|a| = {expect}");
}
#[test]
fn j2_acceleration_matches_closed_form_at_equator() {
let r = [7.0e6, 0.0, 0.0];
let a = j2_accel(r);
assert!((a[0] + 0.010_967).abs() < 1e-5, "a_J2x = {}", a[0]);
assert!(a[1].abs() < 1e-15 && a[2].abs() < 1e-15);
let ratio = a[0].abs() / two_body_accel(r)[0].abs();
assert!(ratio < 2e-3 && ratio > 1e-3, "J2/two-body = {ratio}");
}
#[test]
fn critical_inclination_freezes_the_perigee() {
let a = 7.0e6;
let crit = (0.8_f64).sqrt().asin();
let rates = j2_secular_rates(a, 0.001, crit);
assert!(
rates.arg_perigee.abs() < 1e-12,
"ω̇ = {} at critical i",
rates.arg_perigee
);
assert!(j2_secular_rates(a, 0.001, 0.5).arg_perigee > 0.0);
assert!(j2_secular_rates(a, 0.001, 1.2).arg_perigee < 0.0);
}
#[test]
fn iss_nodal_regression_is_about_minus_five_degrees_per_day() {
let rates = j2_secular_rates(6.790e6, 0.0007, 51.6_f64.to_radians());
let deg_per_day = rates.raan.to_degrees() * 86_400.0;
assert!(
(deg_per_day - (-5.0)).abs() < 0.6,
"Ω̇ = {deg_per_day} °/day"
);
}
#[test]
fn zonal_field_with_only_j2_reduces_to_the_validated_j2_accel() {
for r in [
[7.0e6, 0.0, 0.0],
[3.0e6, 4.0e6, 5.0e6],
[-6.5e6, 1.2e6, -2.4e6],
] {
let a = zonal_accel(r, &[J2]);
let b = j2_accel(r);
for k in 0..3 {
let scale = b[k].abs().max(1e-6);
assert!(
(a[k] - b[k]).abs() / scale < 1e-12,
"comp {k}: {a:?} vs {b:?}"
);
}
}
}
#[test]
fn zonal_accel_is_the_exact_gradient_of_the_zonal_potential() {
let jn = EARTH_ZONALS_J2_J6;
let h = 50.0; for r in [
[6.9e6, 1.5e6, 2.0e6],
[-4.0e6, 5.0e6, 3.2e6],
[2.0e6, -3.0e6, 6.0e6],
] {
let a = zonal_accel(r, &jn);
for k in 0..3 {
let mut rp = r;
let mut rm = r;
rp[k] += h;
rm[k] -= h;
let fd = (zonal_potential(rp, &jn) - zonal_potential(rm, &jn)) / (2.0 * h);
let scale = a[k].abs().max(1e-9);
assert!(
(a[k] - fd).abs() / scale < 1e-6,
"∇R mismatch comp {k}: analytic {} vs FD {fd}",
a[k]
);
}
}
}
#[test]
fn odd_and_even_zonals_have_their_characteristic_north_south_symmetry() {
let north = [5.0e6, 0.0, 3.0e6];
let south = [5.0e6, 0.0, -3.0e6];
let a2n = zonal_accel(north, &[J2]);
let a2s = zonal_accel(south, &[J2]);
assert!(
(a2n[0] - a2s[0]).abs() / a2n[0].abs() < 1e-12,
"J2 a_x should be even in z"
);
assert!(
(a2n[2] + a2s[2]).abs() / a2n[2].abs() < 1e-12,
"J2 a_z should be odd in z"
);
let a3n = zonal_accel(north, &[0.0, J3]);
let a3s = zonal_accel(south, &[0.0, J3]);
assert!(
(a3n[0] + a3s[0]).abs() / a3n[0].abs() < 1e-12,
"J3 a_x should be odd in z"
);
assert!(
(a3n[2] - a3s[2]).abs() / a3n[2].abs() < 1e-12,
"J3 a_z should be even in z"
);
}
#[test]
fn third_body_accel_is_the_exact_gradient_of_its_potential() {
let s = [1.3e11, 0.6e11, 0.26e11]; for r in [[6.9e6, 1.5e6, 2.0e6], [-2.0e7, 3.0e7, 1.0e7]] {
let a = third_body_accel(r, s, MU_SUN);
let h = 2.0e5;
for k in 0..3 {
let mut rp = r;
let mut rm = r;
rp[k] += h;
rm[k] -= h;
let fd = (third_body_potential(rp, s, MU_SUN)
- third_body_potential(rm, s, MU_SUN))
/ (2.0 * h);
let scale = a[k].abs().max(1e-12);
assert!(
(a[k] - fd).abs() / scale < 1e-5,
"third-body ∇R comp {k}: analytic {} vs FD {fd}",
a[k]
);
}
}
}
#[test]
fn third_body_perturbation_vanishes_at_the_geocentre_and_has_the_textbook_magnitude() {
let s = [1.471e11, 0.0, 0.0];
let a0 = third_body_accel([0.0, 0.0, 0.0], s, MU_SUN);
assert!(
norm(a0) < 1e-18,
"perturbation at geocentre should vanish: {a0:?}"
);
let r = [7.0e6, 0.0, 0.0];
let a = norm(third_body_accel(r, s, MU_SUN));
assert!(
(1e-7..2e-6).contains(&a),
"Sun perturbation on LEO {a} m/s² out of textbook band"
);
}
#[test]
fn higher_zonals_are_a_small_nonzero_correction_to_j2() {
let r = [4.5e6, 2.0e6, 4.8e6];
let a_j2 = zonal_accel(r, &[J2]);
let a_full = zonal_accel(r, &EARTH_ZONALS_J2_J6);
let dmag = ((a_full[0] - a_j2[0]).powi(2)
+ (a_full[1] - a_j2[1]).powi(2)
+ (a_full[2] - a_j2[2]).powi(2))
.sqrt();
let j2mag = (a_j2[0] * a_j2[0] + a_j2[1] * a_j2[1] + a_j2[2] * a_j2[2]).sqrt();
let ratio = dmag / j2mag;
assert!(ratio > 1e-4, "J3..J6 must contribute, ratio = {ratio}");
assert!(
ratio < 5e-2,
"J3..J6 must stay a small correction, ratio = {ratio}"
);
}
#[test]
fn sun_synchronous_inclination_drifts_eastward() {
let rates = j2_secular_rates(7.078e6, 0.0, 98.0_f64.to_radians());
assert!(rates.raan > 0.0, "Ω̇ should be eastward: {}", rates.raan);
}
#[test]
fn srp_pressure_at_1au_is_the_textbook_4_5e_minus_6_pa() {
let p = SRP_PRESSURE_AU;
assert!(
(p - 4.539_8e-6).abs() < 1e-9,
"P☉ = {p} N/m², expected ≈ 4.5398e-6"
);
assert!((4.5e-6..=4.6e-6).contains(&p), "P☉ out of band: {p}");
}
#[test]
fn srp_in_full_sun_pushes_away_from_the_sun_at_textbook_magnitude() {
let sun = [AU_M, 0.0, 0.0];
let r = [7.078e6, 0.0, 0.0]; let (cr, aom) = (1.5, 0.02);
let a = srp_accel(r, sun, cr, aom);
let d = [r[0] - sun[0], r[1] - sun[1], r[2] - sun[2]];
let dn = norm(d);
let inv = 1.0 / dn;
let scale = SRP_PRESSURE_AU * cr * aom * AU_M * AU_M * inv * inv * inv; for k in 0..3 {
assert_eq!(a[k], scale * d[k], "SRP axis {k} mismatch");
}
assert!(a[0] < 0.0, "SRP must push away from the Sun (−x): {}", a[0]);
assert!(a[1] == 0.0 && a[2] == 0.0, "no transverse SRP: {a:?}");
let mag = norm(a);
assert!(
(1.35e-7..=1.37e-7).contains(&mag),
"SRP magnitude {mag} m/s² outside ~1.36e-7 band"
);
}
#[test]
fn srp_inverse_square_law_quarters_with_doubled_sun_distance() {
let r = [7.0e6, 0.0, 0.0];
let a1 = srp_accel(r, [AU_M, 0.0, 0.0], 1.5, 0.02);
let a2 = srp_accel(r, [2.0 * AU_M, 0.0, 0.0], 1.5, 0.02);
let ratio = norm(a2) / norm(a1);
assert!(
(0.249..=0.251).contains(&ratio),
"inverse-square ratio {ratio}, expected ≈ 0.25"
);
}
#[test]
fn cylindrical_shadow_eclipses_only_the_umbral_cylinder() {
let sun = [AU_M, 0.0, 0.0];
assert_eq!(cylindrical_shadow([-7.078e6, 0.0, 0.0], sun), 0.0);
assert_eq!(cylindrical_shadow([-1.0e6, 8.0e6, 0.0], sun), 1.0);
assert_eq!(cylindrical_shadow([7.078e6, 0.0, 0.0], sun), 1.0);
let a = srp_accel([-7.078e6, 0.0, 0.0], sun, 1.5, 0.02);
assert_eq!(a, [0.0, 0.0, 0.0]);
}
#[test]
fn conical_shadow_is_one_in_full_sun_and_zero_deep_in_the_umbra() {
let sun = [AU_M, 0.0, 0.0];
assert_eq!(conical_shadow([7.078e6, 0.0, 0.0], sun), 1.0);
assert_eq!(conical_shadow([-7.078e6, 0.0, 0.0], sun), 0.0);
}
#[test]
fn conical_shadow_has_a_smooth_monotonic_penumbra() {
let sun = [AU_M, 0.0, 0.0];
let rn = RE_EARTH + 700e3;
let a = (SOLAR_RADIUS / AU_M).asin(); let b = (RE_EARTH / rn).asin(); let mk = |c: f64| [-rn * c.cos(), -rn * c.sin(), 0.0];
let deep = conical_shadow(mk(b - 2.0 * a), sun); let mid = conical_shadow(mk(b), sun); let shallow = conical_shadow(mk(b + 2.0 * a), sun); assert_eq!(deep, 0.0, "c ≤ b−a must be total umbra");
assert_eq!(shallow, 1.0, "c ≥ a+b must be full sun");
assert!(
(0.3..=0.7).contains(&mid),
"at c = b the Sun is ~half occulted, ν = {mid}"
);
assert!(
deep < mid && mid < shallow,
"ν must rise monotonically out of shadow"
);
}
#[test]
fn conical_penumbra_extends_beyond_the_umbral_cylinder() {
let sun = [AU_M, 0.0, 0.0];
let rn = RE_EARTH + 700e3;
let a = (SOLAR_RADIUS / AU_M).asin();
let b = (RE_EARTH / rn).asin();
let r = [-rn * (b + 0.5 * a).cos(), -rn * (b + 0.5 * a).sin(), 0.0];
assert_eq!(cylindrical_shadow(r, sun), 1.0);
let nu = conical_shadow(r, sun);
assert!(
(0.0..1.0).contains(&nu) && nu > 0.0,
"cone should see partial shadow where the cylinder sees full sun: ν = {nu}"
);
}
#[test]
fn atmospheric_density_anchors_at_sea_level_and_decays_with_altitude() {
let rho0 = atmospheric_density(0.0);
assert!(
(rho0 - 1.225).abs() < 1e-3,
"surface density {rho0}, expected 1.225 kg/m³"
);
assert_eq!(
atmospheric_density(-5_000.0),
rho0,
"clamps below the surface"
);
let alts = [0.0, 100e3, 200e3, 300e3, 400e3, 500e3, 800e3, 1000e3];
for w in alts.windows(2) {
let (lo, hi) = (atmospheric_density(w[0]), atmospheric_density(w[1]));
assert!(
hi < lo,
"density must decrease: {hi} at {} km not < {lo} at {} km",
w[1] / 1e3,
w[0] / 1e3
);
}
let r400 = atmospheric_density(400e3);
assert!(
(1e-12..=1e-11).contains(&r400),
"400 km density {r400} kg/m³ outside ~1e-12 band"
);
}
#[test]
fn atmospheric_density_local_scale_height_is_physical_at_leo() {
let (h1, h2) = (400e3, 440e3);
let ratio = atmospheric_density(h2) / atmospheric_density(h1);
let scale_km = -(h2 - h1) / 1000.0 / ratio.ln();
assert!(
(50.0..=70.0).contains(&scale_km),
"recovered LEO scale height {scale_km} km outside ~50–70 band"
);
}
#[test]
fn drag_opposes_the_corotating_relative_velocity_at_textbook_leo_magnitude() {
let alt = 400e3;
let r = [RE_EARTH + alt, 0.0, 0.0];
let vcirc = (MU_EARTH / (RE_EARTH + alt)).sqrt(); let v = [0.0, vcirc, 0.0];
let a = drag_accel(r, v, 0.02);
let v_rel = [0.0, vcirc - EARTH_ROTATION_RATE * (RE_EARTH + alt), 0.0];
let dot_av = a[0] * v_rel[0] + a[1] * v_rel[1] + a[2] * v_rel[2];
assert!(
dot_av < 0.0,
"drag must oppose the relative velocity: {dot_av}"
);
assert!(
a[0] == 0.0 && a[2] == 0.0,
"drag should be purely along −v_rel: {a:?}"
);
let mag = norm(a);
assert!(
(1e-7..=1e-5).contains(&mag),
"400 km drag magnitude {mag} m/s² outside ~2e-6 band"
);
}
#[test]
fn relativistic_accel_on_a_circular_orbit_is_radial_with_the_closed_form_magnitude() {
let r0 = 7.0e6;
let r = [r0, 0.0, 0.0];
let vcirc = (MU_EARTH / r0).sqrt();
let v = [0.0, vcirc, 0.0];
let a = relativistic_accel(r, v);
assert!(
a[1] == 0.0 && a[2] == 0.0,
"circular GR term not radial: {a:?}"
);
let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
let expected = 3.0 * MU_EARTH * MU_EARTH / (c2 * r0 * r0 * r0);
assert!(
a[0] > 0.0,
"GR correction must push radially outward: {a:?}"
);
let rel = (a[0] - expected).abs() / expected;
assert!(
rel < 1e-9,
"circular GR magnitude {} vs closed form {expected} (rel {rel})",
a[0]
);
}
#[test]
fn relativistic_accel_is_order_1e9_of_two_body_at_leo() {
let r0 = 7.0e6;
let r = [r0, 0.0, 0.0];
let vcirc = (MU_EARTH / r0).sqrt();
let v = [0.0, vcirc, 0.0];
let rel_mag = norm(relativistic_accel(r, v));
let two_body = norm(two_body_accel(r));
let ratio = rel_mag / two_body;
assert!(
(1e-9..3e-9).contains(&ratio),
"GR/two-body ratio {ratio} outside the expected ~1.9e-9 LEO band"
);
}
#[test]
fn relativistic_accel_radial_velocity_matches_the_hand_simplified_form() {
let r0 = 1.0e7;
let vx = 1000.0;
let r = [r0, 0.0, 0.0];
let v = [vx, 0.0, 0.0];
let a = relativistic_accel(r, v);
assert!(
a[1] == 0.0 && a[2] == 0.0,
"radial-velocity GR term must stay on the x-axis: {a:?}"
);
let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
let expected = MU_EARTH * (4.0 * MU_EARTH + 3.0 * vx * vx * r0) / (c2 * r0 * r0 * r0);
let rel = (a[0] - expected).abs() / expected;
assert!(
rel < 1e-12,
"radial GR x-component {} vs closed form {expected} (rel {rel})",
a[0]
);
}
#[test]
fn lense_thirring_vanishes_without_earth_rotation() {
let r = [7.0e6, 0.0, 1.0e6];
let vcirc = (MU_EARTH / 7.0e6).sqrt();
let v = [0.0, vcirc, 0.0];
let a = lense_thirring_accel(r, v);
assert!(
a.iter().any(|x| x.abs() > 0.0),
"LT must be non-zero: {a:?}"
);
let c2 = SPEED_OF_LIGHT * SPEED_OF_LIGHT;
let rn = norm(r);
let j2 = [0.0, 0.0, 2.0 * EARTH_ANGULAR_MOMENTUM_SPECIFIC];
let pre = 2.0 * MU_EARTH / (c2 * rn * rn * rn);
let rxv = [
r[1] * v[2] - r[2] * v[1],
r[2] * v[0] - r[0] * v[2],
r[0] * v[1] - r[1] * v[0],
];
let vxj = [
v[1] * j2[2] - v[2] * j2[1],
v[2] * j2[0] - v[0] * j2[2],
v[0] * j2[1] - v[1] * j2[0],
];
let rdotj = r[2] * j2[2];
let f = 3.0 / (rn * rn);
let a2x = pre * (f * rxv[0] * rdotj + vxj[0]);
assert!(
(a2x - 2.0 * a[0]).abs() / (2.0 * a[0]).abs() < 1e-12,
"LT must be linear in J"
);
}
#[test]
fn lense_thirring_is_far_below_schwarzschild_at_leo() {
let r0 = 7.0e6;
let r = [r0, 0.0, 0.5e6];
let vcirc = (MU_EARTH / r0).sqrt();
let v = [0.0, vcirc, 0.0];
let lt = norm(lense_thirring_accel(r, v));
let schwarz = norm(relativistic_accel(r, v));
let ratio = lt / schwarz;
assert!(lt.is_finite() && lt > 0.0);
assert!(
(1e-3..0.5).contains(&ratio),
"LT/Schwarzschild ratio {ratio} outside the expected order-of-magnitude band"
);
}
}