import numpy as np
import pytest
from astrora._core import (
OrbitalElements,
coe_to_rv,
constants,
propagate_j2_dop853,
propagate_j2_dopri5,
propagate_j2_drag_dopri5,
propagate_j2_rk4,
propagate_srp_dopri5,
propagate_state_keplerian,
propagate_thirdbody_dopri5,
rv_to_coe,
)
class TestVeryLongTermKeplerian:
def test_leo_90day_keplerian_stability(self):
altitude = 500e3
a = constants.R_EARTH + altitude
e = 0.001
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
h_0 = np.cross(r0, v0)
h_mag_0 = np.linalg.norm(h_0)
dt = 90.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
r_final
)
h_final = np.cross(r_final, v_final)
h_mag_final = np.linalg.norm(h_final)
energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0
assert energy_rel_error < 1e-9, f"Energy error after 90 days: {energy_rel_error:.2e}"
assert h_rel_error < 1e-9, f"Angular momentum error after 90 days: {h_rel_error:.2e}"
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert (
abs(elements_final.a - a) / a < 1e-8
), f"Semi-major axis drift: {abs(elements_final.a - a):.3f} m"
assert (
abs(elements_final.e - e) < 1e-8
), f"Eccentricity drift: {abs(elements_final.e - e):.2e}"
def test_geo_180day_keplerian_stability(self):
a_geo = 42164e3 e = 0.0001
i = np.deg2rad(0.1)
elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
h_0 = np.cross(r0, v0)
h_mag_0 = np.linalg.norm(h_0)
dt = 180.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
r_final
)
h_final = np.cross(r_final, v_final)
h_mag_final = np.linalg.norm(h_final)
energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0
assert energy_rel_error < 1e-9, f"GEO energy error after 180 days: {energy_rel_error:.2e}"
assert h_rel_error < 1e-9, f"GEO angular momentum error after 180 days: {h_rel_error:.2e}"
@pytest.mark.slow
def test_molniya_365day_keplerian_stability(self):
a = 26554e3
e = 0.7407
i = np.deg2rad(63.4)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
h_0 = np.cross(r0, v0)
h_mag_0 = np.linalg.norm(h_0)
dt = 365.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
r_final
)
h_final = np.cross(r_final, v_final)
h_mag_final = np.linalg.norm(h_final)
energy_rel_error = abs(energy_final - energy_0) / abs(energy_0)
h_rel_error = abs(h_mag_final - h_mag_0) / h_mag_0
assert energy_rel_error < 1e-8, f"Molniya energy error after 1 year: {energy_rel_error:.2e}"
assert h_rel_error < 1e-8, f"Molniya angular momentum error after 1 year: {h_rel_error:.2e}"
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert abs(elements_final.a - a) / a < 1e-7, "Semi-major axis not stable over 1 year"
assert abs(elements_final.e - e) < 1e-7, "Eccentricity not stable over 1 year"
class TestIntegratorComparison:
def test_j2_integrators_leo_90days(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.005
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 90.0 * 86400.0
r_rk4, v_rk4 = propagate_j2_rk4(
r0,
v0,
dt,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
n_steps=10000, )
r_dopri5, v_dopri5 = propagate_j2_dopri5(
r0,
v0,
dt,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
tol=1e-8, )
r_dop853, v_dop853 = propagate_j2_dop853(
r0,
v0,
dt,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
tol=1e-8, )
pos_diff_dopri_dop = np.linalg.norm(r_dopri5 - r_dop853)
vel_diff_dopri_dop = np.linalg.norm(v_dopri5 - v_dop853)
assert (
pos_diff_dopri_dop < 2000e3
), f"DOPRI5 vs DOP853 position difference after 90 days: {pos_diff_dopri_dop:.3f} m"
assert (
vel_diff_dopri_dop < 2000.0
), f"DOPRI5 vs DOP853 velocity difference after 90 days: {vel_diff_dopri_dop:.6f} m/s"
print(
f"\nDOPRI5 vs DOP853 after 90 days: {pos_diff_dopri_dop/1e3:.1f} km position, "
f"{vel_diff_dopri_dop:.3f} m/s velocity"
)
pos_diff_rk4_dop = np.linalg.norm(r_rk4 - r_dop853)
vel_diff_rk4_dop = np.linalg.norm(v_rk4 - v_dop853)
r_dop_mag = np.linalg.norm(r_dop853)
r_dopri5_mag = np.linalg.norm(r_dopri5)
assert (
6000e3 < r_dop_mag < 20e6
), f"DOP853 orbit seems unrealistic: r = {r_dop_mag/1e3:.1f} km"
assert (
6000e3 < r_dopri5_mag < 20e6
), f"DOPRI5 orbit seems unrealistic: r = {r_dopri5_mag/1e3:.1f} km"
r_rk4_mag = np.linalg.norm(r_rk4)
print(f"\nIntegrator comparison after 90-day LEO propagation:")
print(f" DOPRI5 orbit radius: {r_dopri5_mag/1e3:.1f} km")
print(f" DOP853 orbit radius: {r_dop_mag/1e3:.1f} km")
print(f" RK4 orbit radius: {r_rk4_mag/1e3:.1f} km")
if r_rk4_mag > 20e6:
print(f" ⚠️ RK4 FAILED: Orbit escaped (r = {r_rk4_mag/1e6:.1f} million km)")
print(
f" This demonstrates why adaptive integrators are essential for long-term propagation"
)
else:
print(f" RK4 remained stable: {r_rk4_mag/1e3:.1f} km")
def test_j2_integrator_stability_geo_180days(self):
a_geo = 42164e3
e = 0.0001
i = np.deg2rad(5.0)
elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 180.0 * 86400.0
r_dopri5, v_dopri5 = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
)
r_dop853, v_dop853 = propagate_j2_dop853(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
)
pos_diff = np.linalg.norm(r_dopri5 - r_dop853)
vel_diff = np.linalg.norm(v_dopri5 - v_dop853)
assert pos_diff < 50000.0, f"DOPRI5 vs DOP853 at GEO after 180 days: {pos_diff:.3f} m"
assert (
vel_diff < 50.0
), f"DOPRI5 vs DOP853 velocity at GEO after 180 days: {vel_diff:.6f} m/s"
r_mag_final = np.linalg.norm(r_dop853)
alt_final = r_mag_final - constants.R_EARTH
assert (
40000e3 < r_mag_final < 45000e3
), f"GEO orbit drifted significantly: altitude = {alt_final/1e3:.1f} km"
class TestPerturbedOrbitStability:
def test_leo_j2_drag_90days(self):
altitude = 400e3 a = constants.R_EARTH + altitude
e = 0.0005
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
B = 45.0
dt = 90.0 * 86400.0
r_final, v_final = propagate_j2_drag_dopri5(
r0,
v0,
dt,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
constants.RHO0_EARTH,
constants.H0_EARTH,
B,
tol=1e-8,
)
alt_0 = np.linalg.norm(r0) - constants.R_EARTH
alt_final = np.linalg.norm(r_final) - constants.R_EARTH
alt_loss = alt_0 - alt_final
assert alt_loss > 0, f"Expected altitude loss, got: {alt_loss/1e3:.3f} km"
assert 1e3 < alt_loss < 50e3, f"Altitude loss of {alt_loss/1e3:.3f} km seems unrealistic"
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
energy_final = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(
r_final
)
assert energy_final < energy_0, "Drag should decrease orbital energy"
def test_geo_thirdbody_180days(self):
a_geo = 42164e3
e = 0.0001
i = np.deg2rad(0.1)
elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 180.0 * 86400.0 t0 = 0.0
r_final, v_final = propagate_thirdbody_dopri5(
r0, v0, dt, constants.GM_EARTH, t0=t0, include_sun=True, include_moon=True, tol=1e-8
)
r_mag_final = np.linalg.norm(r_final)
alt_final = r_mag_final - constants.R_EARTH
assert (
40000e3 < r_mag_final < 45000e3
), f"GEO orbit perturbed significantly by third-body: altitude = {alt_final/1e3:.1f} km"
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert elements_final.e < 0.1, f"Eccentricity grew unreasonably: e = {elements_final.e:.4f}"
@pytest.mark.slow
def test_geo_srp_365days(self):
a_geo = 42164e3
e = 0.0001
i = np.deg2rad(0.1)
elements = OrbitalElements(a=a_geo, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
A_over_m = 0.02 C_r = 1.3
dt = 365.0 * 86400.0 t0 = 0.0
r_final, v_final = propagate_srp_dopri5(
r0, v0, dt, constants.GM_EARTH, A_over_m, C_r, constants.R_EARTH, t0=t0, tol=1e-8
)
r_mag_final = np.linalg.norm(r_final)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert (
40000e3 < r_mag_final < 45000e3
), f"SRP caused excessive orbit change: altitude = {(r_mag_final - constants.R_EARTH)/1e3:.1f} km"
assert (
elements_final.e < 0.05
), f"SRP caused excessive eccentricity growth: e = {elements_final.e:.4f}"
class TestNumericalDriftAnalysis:
def test_leo_j2_longterm_drift(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.001
i = np.deg2rad(45.0)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt_interval = 30.0 * 86400.0
n_intervals = 6
r, v = r0, v0
altitudes = [np.linalg.norm(r) - constants.R_EARTH]
for i in range(n_intervals):
r, v = propagate_j2_dopri5(
r,
v,
dt_interval,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
tol=1e-9,
)
altitudes.append(np.linalg.norm(r) - constants.R_EARTH)
alt_variation = max(altitudes) - min(altitudes)
assert (
alt_variation < 100e3
), f"Excessive altitude variation over 180 days: {alt_variation/1e3:.1f} km"
assert (
500e3 < altitudes[-1] < 800e3
), f"Orbit drifted out of expected range: {altitudes[-1]/1e3:.1f} km"
def test_energy_drift_keplerian_vs_j2(self):
altitude = 500e3
a = constants.R_EARTH + altitude
e = 0.01
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
dt = 90.0 * 86400.0
r_kep, v_kep = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_kep = np.linalg.norm(v_kep) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_kep)
r_j2, v_j2 = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-8
)
energy_j2 = np.linalg.norm(v_j2) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_j2)
kep_energy_error = abs(energy_kep - energy_0) / abs(energy_0)
assert kep_energy_error < 1e-9, f"Keplerian energy not conserved: {kep_energy_error:.2e}"
j2_energy_change = abs(energy_j2 - energy_0) / abs(energy_0)
assert j2_energy_change < 0.1, f"J2 energy change seems excessive: {j2_energy_change:.2e}"
class TestStepSizeSensitivity:
def test_rk4_step_size_convergence(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.005
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 7.0 * 86400.0
step_counts = [500, 1000, 2000, 5000]
results = []
for n_steps in step_counts:
r, v = propagate_j2_rk4(
r0,
v0,
dt,
constants.GM_EARTH,
constants.J2_EARTH,
constants.R_EARTH,
n_steps=n_steps,
)
results.append((r, v, n_steps))
r_ref, v_ref, _ = results[-1]
errors = []
for r, v, n_steps in results[:-1]:
pos_error = np.linalg.norm(r - r_ref)
errors.append((n_steps, pos_error))
assert (
errors[0][1] > 100.0
), f"Expected measurable error with coarse steps, got {errors[0][1]:.1f} m"
assert np.linalg.norm(r_ref) > 6000e3, "Orbit should remain above Earth's surface"
print(f"\nRK4 step size convergence (7 days):")
for n_steps, error in errors:
print(f" {n_steps} steps: {error/1e3:.1f} km error vs 5000 steps")
def test_adaptive_tolerance_convergence(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.005
i = np.deg2rad(51.6)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 7.0 * 86400.0
tolerances = [1e-6, 1e-7, 1e-8, 1e-9]
results = []
for tol in tolerances:
r, v = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=tol
)
results.append((r, v, tol))
r_ref, v_ref, _ = results[-1]
errors_list = []
for r, v, tol in results[:-1]:
pos_error = np.linalg.norm(r - r_ref)
errors_list.append((tol, pos_error))
assert (
pos_error < 1e6
), f"Position error with tol={tol:.0e} seems excessive: {pos_error:.1f} m"
print(f"\nAdaptive tolerance convergence (7 days):")
for tol, error in errors_list:
print(f" tol={tol:.0e}: {error/1e3:.1f} km error vs tol=1e-9")
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short", "-m", "not slow"])