import numpy as np
import pytest
from astrora._core import (
OrbitalElements,
coe_to_rv,
constants,
propagate_j2_dopri5,
propagate_j2_rk4,
propagate_state_keplerian,
rv_to_coe,
)
class TestForwardBackwardReversibility:
def test_leo_reversibility_1day(self):
altitude = 408e3 a = constants.R_EARTH + altitude
e = 0.0005
i = np.deg2rad(51.64)
elements = OrbitalElements(
a=a, e=e, i=i, raan=np.deg2rad(100.0), argp=np.deg2rad(45.0), nu=np.deg2rad(30.0)
)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt_forward = 86400.0 r_fwd, v_fwd = propagate_state_keplerian(r0, v0, dt_forward, constants.GM_EARTH)
dt_backward = -86400.0
r_final, v_final = propagate_state_keplerian(r_fwd, v_fwd, dt_backward, constants.GM_EARTH)
pos_error = np.linalg.norm(r_final - r0)
vel_error = np.linalg.norm(v_final - v0)
assert (
pos_error < 1.0
), f"LEO 1-day reversibility position error: {pos_error:.6f} m (target: < 1 m)"
assert (
vel_error < 0.001
), f"LEO 1-day reversibility velocity error: {vel_error:.6f} m/s (target: < 0.001 m/s)"
def test_molniya_reversibility_3days(self):
a = 26554e3 e = 0.7407
i = np.deg2rad(63.4)
elements = OrbitalElements(
a=a, e=e, i=i, raan=np.deg2rad(200.0), argp=np.deg2rad(-90.0), nu=np.deg2rad(45.0)
)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 3.0 * 86400.0
r_fwd, v_fwd = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
r_final, v_final = propagate_state_keplerian(r_fwd, v_fwd, -dt, constants.GM_EARTH)
pos_error = np.linalg.norm(r_final - r0)
vel_error = np.linalg.norm(v_final - v0)
assert (
pos_error < 10.0
), f"Molniya 3-day reversibility position error: {pos_error:.3f} m (target: < 10 m)"
assert vel_error < 0.01, f"Molniya 3-day reversibility velocity error: {vel_error:.6f} m/s"
def test_geo_reversibility_7days(self):
a_geo = 42164e3
elements = OrbitalElements(
a=a_geo,
e=0.0001, i=np.deg2rad(0.05), raan=0.0,
argp=0.0,
nu=np.deg2rad(120.0),
)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 7.0 * 86400.0
r_fwd, v_fwd = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
r_final, v_final = propagate_state_keplerian(r_fwd, v_fwd, -dt, constants.GM_EARTH)
pos_error = np.linalg.norm(r_final - r0)
vel_error = np.linalg.norm(v_final - v0)
assert (
pos_error < 1.0
), f"GEO 7-day reversibility position error: {pos_error:.6f} m (target: < 1 m)"
assert vel_error < 0.001, f"GEO 7-day reversibility velocity error: {vel_error:.6f} m/s"
class TestLongTermPropagationStability:
def test_leo_30day_stability(self):
altitude = 500e3
a = constants.R_EARTH + altitude
e = 0.0
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)
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 = 30.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-10
), f"Energy not conserved over 30 days: relative error = {energy_rel_error:.2e}"
assert (
h_rel_error < 1e-10
), f"Angular momentum not conserved over 30 days: relative error = {h_rel_error:.2e}"
def test_molniya_60day_stability(self):
a = 26554e3
e = 0.7407
i = np.deg2rad(63.4)
raan = np.deg2rad(270.0)
argp = np.deg2rad(-90.0)
nu = 0.0
elements_0 = OrbitalElements(a=a, e=e, i=i, raan=raan, argp=argp, nu=nu)
r0, v0 = coe_to_rv(elements_0, constants.GM_EARTH)
dt = 60.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert abs(elements_final.a - a) / a < 1e-8, "Semi-major axis drifted over 60 days"
assert abs(elements_final.e - e) < 1e-8, "Eccentricity drifted over 60 days"
assert abs(elements_final.i - i) < 1e-10, "Inclination drifted over 60 days"
class TestPropagatorComparison:
def test_keplerian_vs_j2_rk4_leo_1day(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)
dt = 86400.0 r_kep, v_kep = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
r_j2, v_j2 = propagate_j2_rk4(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, n_steps=1000
)
pos_diff = np.linalg.norm(r_j2 - r_kep)
assert (
100.0 < pos_diff < 2e6
), f"J2 vs Keplerian difference unexpected: {pos_diff:.1f} m (expected 100m - 2000km)"
energy_kep = np.linalg.norm(v_kep) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_kep)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
kep_energy_error = abs(energy_kep - energy_0) / abs(energy_0)
assert kep_energy_error < 1e-10, f"Keplerian energy not conserved: {kep_energy_error:.2e}"
def test_j2_rk4_vs_dopri5_comparison(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.005
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 = 86400.0 r_rk4, v_rk4 = propagate_j2_rk4(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, n_steps=1000
)
r_dopri, v_dopri = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-8
)
pos_diff = np.linalg.norm(r_dopri - r_rk4)
vel_diff = np.linalg.norm(v_dopri - v_rk4)
assert (
pos_diff < 20000.0
), f"RK4 vs DOPRI5 position difference: {pos_diff:.3f} m (target: < 20 km)"
assert (
vel_diff < 15.0
), f"RK4 vs DOPRI5 velocity difference: {vel_diff:.6f} m/s (target: < 15 m/s)"
class TestStandardOrbitRegimes:
def test_leo_regime_coverage(self):
test_altitudes = [300e3, 500e3, 800e3, 1500e3]
for alt in test_altitudes:
a = constants.R_EARTH + alt
elements = OrbitalElements(a=a, e=0.001, i=np.deg2rad(45.0), raan=0.0, argp=0.0, nu=0.0)
r, v = coe_to_rv(elements, constants.GM_EARTH)
altitude_check = np.linalg.norm(r) - constants.R_EARTH
assert (
200e3 < altitude_check < 2000e3
), f"Orbit not in LEO range: {altitude_check/1e3:.1f} km"
period = elements.orbital_period(constants.GM_EARTH)
r_prop, v_prop = propagate_state_keplerian(r, v, period, constants.GM_EARTH)
pos_error = np.linalg.norm(r_prop - r)
assert pos_error < 1.0, f"LEO {alt/1e3}km: 1-orbit position error = {pos_error:.3f} m"
def test_meo_gps_orbit(self):
a_gps = constants.R_EARTH + 20200e3 e = 0.01 i = np.deg2rad(55.0)
elements = OrbitalElements(a=a_gps, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
period = elements.orbital_period(constants.GM_EARTH)
period_hours = period / 3600.0
assert (
11.9 < period_hours < 12.1
), f"GPS orbit period: {period_hours:.2f} hours (expected ~12 hours)"
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 7.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
energy_f = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_final)
rel_error = abs(energy_f - energy_0) / abs(energy_0)
assert rel_error < 1e-10, f"GPS orbit energy conservation over 7 days: {rel_error:.2e}"
def test_heo_tundra_orbit(self):
a_tundra = 42164e3 e_tundra = 0.3 i_tundra = np.deg2rad(63.4)
elements = OrbitalElements(
a=a_tundra, e=e_tundra, i=i_tundra, raan=0.0, argp=np.deg2rad(-90.0), nu=0.0
)
period = elements.orbital_period(constants.GM_EARTH)
period_hours = period / 3600.0
assert (
23.9 < period_hours < 24.1
), f"Tundra period: {period_hours:.2f} hours (expected ~24 hours)"
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 10.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
assert (
abs(elements_final.a - a_tundra) / a_tundra < 1e-8
), "Tundra semi-major axis not preserved"
assert abs(elements_final.e - e_tundra) < 1e-8, "Tundra eccentricity not preserved"
class TestNumericalAccuracy:
def test_keplerian_precision_short_term(self):
a = constants.R_EARTH + 500e3
e = 0.01
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)
period = elements.orbital_period(constants.GM_EARTH)
dt = period / 4.0
r_prop, v_prop = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
energy_f = np.linalg.norm(v_prop) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_prop)
rel_error = abs(energy_f - energy_0) / abs(energy_0)
assert (
rel_error < 1e-12
), f"Keplerian energy error over 1/4 orbit: {rel_error:.2e} (target: < 1e-12)"
def test_integration_step_size_convergence(self):
a = constants.R_EARTH + 600e3
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 = 86400.0
step_sizes = [2000, 1000, 500] results = []
for n_steps in step_sizes:
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]
prev_diff = 0.0
for i, (r, v, n_steps) in enumerate(results[:-1]): pos_diff = np.linalg.norm(r - r_ref)
if i > 0:
assert pos_diff >= prev_diff * 0.5, (
f"Expected convergence: {n_steps} steps error ({pos_diff:.1f}m) "
f"should be >= half of previous error ({prev_diff:.1f}m)"
)
prev_diff = pos_diff
assert (
pos_diff < 500000.0
), f"J2 propagation with {n_steps} steps differs by {pos_diff:.1f} m from reference"
class TestOrbitTypeClassification:
def test_circular_orbit_detection(self):
a = constants.R_EARTH + 700e3
e = 0.0001
elements = OrbitalElements(a=a, e=e, i=0.0, raan=0.0, argp=0.0, nu=0.0)
assert elements.e < 0.01, "Orbit should be classified as nearly circular"
r_p = elements.periapsis_distance
r_a = elements.apoapsis_distance
rel_diff = abs(r_a - r_p) / r_a
assert (
rel_diff < 0.01
), f"Circular orbit: apoapsis and periapsis differ by {rel_diff*100:.2f}%"
def test_elliptical_orbit_range(self):
test_eccentricities = [0.1, 0.3, 0.5, 0.7, 0.9]
for e in test_eccentricities:
elements = OrbitalElements(
a=20000e3, e=e, i=np.deg2rad(30.0), raan=0.0, argp=0.0, nu=0.0
)
assert 0.0 < elements.e < 1.0, f"Eccentricity {e} should be elliptical"
r, v = coe_to_rv(elements, constants.GM_EARTH)
energy = np.linalg.norm(v) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r)
assert energy < 0, f"Elliptical orbit (e={e}) should have negative energy"
class TestPropagationPerformance:
@pytest.mark.slow
def test_very_long_propagation_leo(self):
a = constants.R_EARTH + 500e3
elements = OrbitalElements(a=a, e=0.001, i=np.deg2rad(51.6), raan=0.0, argp=0.0, nu=0.0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 365.0 * 86400.0
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
energy_0 = np.linalg.norm(v0) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r0)
energy_f = np.linalg.norm(v_final) ** 2 / 2.0 - constants.GM_EARTH / np.linalg.norm(r_final)
rel_error = abs(energy_f - energy_0) / abs(energy_0)
assert rel_error < 1e-9, f"Energy error after 1 year: {rel_error:.2e}"
def test_multiple_orbit_propagation(self):
a = constants.R_EARTH + 700e3
e = 0.02
i = np.deg2rad(45.0)
nu_0 = np.deg2rad(30.0)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=nu_0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
period = elements.orbital_period(constants.GM_EARTH)
dt = 10.0 * period
r_final, v_final = propagate_state_keplerian(r0, v0, dt, constants.GM_EARTH)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
nu_diff = abs(elements_final.nu - nu_0)
nu_diff_wrapped = min(nu_diff, 2 * np.pi - nu_diff)
assert nu_diff_wrapped < np.deg2rad(
0.1
), f"True anomaly after 10 orbits: {np.rad2deg(nu_diff_wrapped):.4f}° error"
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short", "-m", "not slow"])