import numpy as np
import pytest
from astrora._core import (
OrbitalElements,
coe_to_rv,
constants,
mean_to_true_anomaly,
propagate_j2_dopri5,
propagate_state_keplerian,
rv_to_coe,
true_to_mean_anomaly,
)
class TestCurtisExamples:
def test_curtis_example_2_2_orbital_period(self):
r_perigee = constants.R_EARTH + 300e3 r_apogee = constants.R_EARTH + 3000e3
a = (r_perigee + r_apogee) / 2.0
T = 2.0 * np.pi * np.sqrt(a**3 / constants.GM_EARTH)
expected_period = 7159.0
rel_error = abs(T - expected_period) / expected_period
assert (
rel_error < 0.001
), f"Period mismatch: got {T:.1f} s, expected {expected_period} s (error: {rel_error*100:.3f}%)"
T_minutes = T / 60.0
assert (
90.0 < T_minutes < 120.0
), f"Period out of reasonable range: got {T_minutes:.2f} minutes"
def test_curtis_example_2_5_eccentricity_from_apsides(self):
r_perigee = 9600e3 r_apogee = 21000e3
a = (r_perigee + r_apogee) / 2.0
e = (r_apogee - r_perigee) / (r_apogee + r_perigee)
expected_a = 15300e3 expected_e = 0.3725
assert (
abs(a - expected_a) < 100.0
), f"Semi-major axis mismatch: got {a/1e3:.1f} km, expected {expected_a/1e3:.1f} km"
assert (
abs(e - expected_e) < 0.001
), f"Eccentricity mismatch: got {e:.4f}, expected {expected_e:.4f}"
def test_curtis_example_3_1_specific_energy_angular_momentum(self):
altitude = 1545e3 r = constants.R_EARTH + altitude
v_circular = np.sqrt(constants.GM_EARTH / r)
epsilon = v_circular**2 / 2.0 - constants.GM_EARTH / r
h = r * v_circular
assert (
epsilon < 0
), f"Circular orbit should have negative specific energy, got {epsilon/1e6:.2f} MJ/kg"
expected_epsilon = -constants.GM_EARTH / (2.0 * r)
assert (
abs(epsilon - expected_epsilon) / abs(expected_epsilon) < 1e-10
), f"Specific energy formula mismatch for circular orbit"
expected_h = 5.62e10 assert (
abs(h - expected_h) / expected_h < 0.01
), f"Angular momentum: got {h/1e9:.2f} km²/s, expected ~{expected_h/1e9:.2f} km²/s"
def test_curtis_example_4_7_rv_from_coe(self):
h = 80000e6 e = 1.4
i = np.deg2rad(30.0)
raan = np.deg2rad(40.0)
argp = np.deg2rad(60.0)
nu = np.deg2rad(30.0)
p = h**2 / constants.GM_EARTH
a = p / (1.0 - e**2)
elements = OrbitalElements(a=a, e=e, i=i, raan=raan, argp=argp, nu=nu)
r, v = coe_to_rv(elements, constants.GM_EARTH)
expected_r = np.array([-4040e3, 4815e3, 3629e3])
expected_v = np.array([-10.39e3, -4.772e3, 1.744e3])
r_error = np.linalg.norm(r - expected_r)
assert r_error < 1000.0, (
f"Position mismatch: error = {r_error/1e3:.2f} km\n"
f" Got: {r/1e3}\n Expected: {expected_r/1e3}"
)
v_error = np.linalg.norm(v - expected_v)
assert v_error < 10.0, (
f"Velocity mismatch: error = {v_error:.2f} m/s\n"
f" Got: {v/1e3}\n Expected: {expected_v/1e3}"
)
class TestInterplanetaryTransfers:
def test_hohmann_transfer_leo_to_geo(self):
r1 = constants.R_EARTH + 250e3 v1_circular = np.sqrt(constants.GM_EARTH / r1)
r2 = constants.R_EARTH + 35786e3 v2_circular = np.sqrt(constants.GM_EARTH / r2)
a_transfer = (r1 + r2) / 2.0
v1_transfer = np.sqrt(constants.GM_EARTH * (2.0 / r1 - 1.0 / a_transfer))
v2_transfer = np.sqrt(constants.GM_EARTH * (2.0 / r2 - 1.0 / a_transfer))
delta_v1 = v1_transfer - v1_circular
delta_v2 = v2_circular - v2_transfer
total_delta_v = delta_v1 + delta_v2
expected_v1_circular = 7.755e3 expected_v2_circular = 3.075e3 expected_v1_transfer = 10.195e3 expected_v2_transfer = 1.603e3 expected_total_delta_v = 3.912e3
assert (
abs(v1_circular - expected_v1_circular) < 1.0
), f"LEO velocity: got {v1_circular/1e3:.3f} km/s, expected {expected_v1_circular/1e3:.3f} km/s"
assert (
abs(v2_circular - expected_v2_circular) < 1.0
), f"GEO velocity: got {v2_circular/1e3:.3f} km/s, expected {expected_v2_circular/1e3:.3f} km/s"
assert (
abs(v1_transfer - expected_v1_transfer) < 1.0
), f"Transfer perigee velocity: got {v1_transfer/1e3:.3f} km/s, expected {expected_v1_transfer/1e3:.3f} km/s"
assert (
abs(v2_transfer - expected_v2_transfer) < 1.0
), f"Transfer apogee velocity: got {v2_transfer/1e3:.3f} km/s, expected {expected_v2_transfer/1e3:.3f} km/s"
assert (
abs(total_delta_v - expected_total_delta_v) < 1.0
), f"Total delta-v: got {total_delta_v/1e3:.3f} km/s, expected {expected_total_delta_v/1e3:.3f} km/s"
def test_earth_mars_hohmann_transfer_orbit(self):
r_earth = constants.AU r_mars = 1.524 * constants.AU
a_transfer = (r_earth + r_mars) / 2.0
period_transfer = 2.0 * np.pi * np.sqrt(a_transfer**3 / constants.GM_SUN)
transfer_time = period_transfer / 2.0
expected_a_transfer_au = 1.262 expected_transfer_days = 259.0
a_transfer_au = a_transfer / constants.AU
assert (
abs(a_transfer_au - expected_a_transfer_au) < 0.001
), f"Transfer orbit semi-major axis: got {a_transfer_au:.3f} AU, expected {expected_a_transfer_au:.3f} AU"
transfer_days = transfer_time / 86400.0
assert (
abs(transfer_days - expected_transfer_days) < 1.0
), f"Transfer time: got {transfer_days:.1f} days, expected {expected_transfer_days:.1f} days"
class TestHistoricalMissions:
def test_apollo_11_translunar_injection(self):
r_parking = constants.R_EARTH + 185e3 v_circular = np.sqrt(constants.GM_EARTH / r_parking)
v_escape = np.sqrt(2.0 * constants.GM_EARTH / r_parking)
delta_v_tli = v_escape - v_circular v_after_tli = v_circular + delta_v_tli
epsilon = v_after_tli**2 / 2.0 - constants.GM_EARTH / r_parking
assert (
epsilon >= -1000.0
), f"Apollo 11 TLI should produce near-escape trajectory (ε ≥ 0), got ε = {epsilon:.2e} J/kg"
v_after_tli_magnitude = (
np.linalg.norm(v_after_tli) if isinstance(v_after_tli, np.ndarray) else v_after_tli
)
rel_diff = abs(v_after_tli_magnitude - v_escape) / v_escape
assert rel_diff < 0.01, (
f"TLI velocity should be close to escape velocity: "
f"got {v_after_tli_magnitude/1e3:.3f} km/s vs v_esc = {v_escape/1e3:.3f} km/s"
)
def test_gps_constellation_orbit(self):
a_gps = 26560e3
period = 2.0 * np.pi * np.sqrt(a_gps**3 / constants.GM_EARTH)
v_gps = np.sqrt(constants.GM_EARTH / a_gps)
expected_period_hours = 12.0 expected_period_seconds = expected_period_hours * 3600.0
period_error = abs(period - expected_period_seconds)
assert (
period_error < 300.0
), f"GPS orbital period: got {period/3600:.2f} hours, expected {expected_period_hours:.2f} hours"
expected_v_gps = 3.87e3 v_error = abs(v_gps - expected_v_gps)
assert (
v_error < 20.0
), f"GPS orbital velocity: got {v_gps/1e3:.3f} km/s, expected ~{expected_v_gps/1e3:.3f} km/s (within 20 m/s)"
def test_hubble_space_telescope_orbit(self):
altitude_hst = 540e3 r_hst = constants.R_EARTH + altitude_hst
inclination = np.deg2rad(28.5)
period = 2.0 * np.pi * np.sqrt(r_hst**3 / constants.GM_EARTH)
period_minutes = period / 60.0
expected_period_min = 95.0
expected_period_max = 96.0
assert (
expected_period_min < period_minutes < expected_period_max
), f"HST orbital period: got {period_minutes:.1f} minutes, expected 95-96 minutes"
class TestPerturbationRegression:
def test_j2_precession_rate_sun_synchronous(self):
altitude = 800e3 a = constants.R_EARTH + altitude
e = 0.001 i = np.deg2rad(98.6)
n = np.sqrt(constants.GM_EARTH / a**3)
precession_rate = (
-(3.0 / 2.0) * n * constants.J2_EARTH * (constants.R_EARTH / a) ** 2 * np.cos(i)
)
precession_deg_per_day = np.rad2deg(precession_rate) * 86400.0
expected_precession = 0.9856
error = abs(precession_deg_per_day - expected_precession)
assert error < 0.01, (
f"Sun-synchronous precession rate: got {precession_deg_per_day:.4f} °/day, "
f"expected {expected_precession:.4f} °/day"
)
def test_j2_propagation_10_days_leo(self):
altitude = 408e3
a = constants.R_EARTH + altitude
e = 0.0005
i = np.deg2rad(51.64)
raan_0 = np.deg2rad(100.0)
argp_0 = np.deg2rad(45.0)
nu_0 = np.deg2rad(30.0)
elements = OrbitalElements(a=a, e=e, i=i, raan=raan_0, argp=argp_0, nu=nu_0)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 10.0 * 86400.0 r_final, v_final = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
raan_drift = elements_final.raan - raan_0
assert (
raan_drift < 0
), f"J2 RAAN drift should be negative for i < 90°, got {np.rad2deg(raan_drift):.3f}°"
assert (
abs(np.rad2deg(raan_drift)) > 1.0
), f"J2 RAAN drift over 10 days should be > 1°, got {abs(np.rad2deg(raan_drift)):.3f}°"
expected_raan_drift_deg = -49.7 actual_raan_drift_deg = np.rad2deg(raan_drift)
assert abs(actual_raan_drift_deg - expected_raan_drift_deg) < 1.0, (
f"J2 RAAN drift regression: got {actual_raan_drift_deg:.2f}°, "
f"expected ~{expected_raan_drift_deg:.2f}° (regression baseline, ±1.0°)"
)
def test_j2_frozen_orbit_eccentricity_stability(self):
altitude = 600e3
a = constants.R_EARTH + altitude
e = 0.001 i = np.deg2rad(97.0) argp = np.deg2rad(90.0)
elements = OrbitalElements(
a=a, e=e, i=i, raan=np.deg2rad(0.0), argp=argp, nu=np.deg2rad(0.0)
)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 30.0 * 86400.0 r_final, v_final = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
e_change = abs(elements_final.e - e)
assert (
e_change < 0.01
), f"Frozen orbit eccentricity change: got Δe = {e_change:.6f}, expected < 0.01"
argp_final = np.rad2deg(elements_final.argp)
assert (
0 <= argp_final <= 360
), f"Frozen orbit ω should be in valid range [0, 360°], got {argp_final:.2f}°"
class TestSpecialOrbitalCases:
def test_nearly_circular_orbit(self):
a = constants.R_EARTH + 500e3
e = 1e-8 i = np.deg2rad(45.0)
elements = OrbitalElements(a=a, e=e, i=i, raan=0.0, argp=0.0, nu=0.0)
r, v = coe_to_rv(elements, constants.GM_EARTH)
elements_back = rv_to_coe(r, v, constants.GM_EARTH)
assert (
abs(elements_back.e - e) < 1e-10
), f"Nearly circular orbit eccentricity: got {elements_back.e:.2e}, expected {e:.2e}"
assert (
abs(elements_back.a - a) / a < 1e-12
), f"Nearly circular orbit semi-major axis error: {abs(elements_back.a - a):.2e} m"
def test_nearly_equatorial_orbit(self):
a = constants.R_EARTH + 35786e3 e = 0.001
i = np.deg2rad(0.01)
elements = OrbitalElements(
a=a,
e=e,
i=i,
raan=np.deg2rad(45.0), argp=np.deg2rad(60.0),
nu=np.deg2rad(30.0),
)
r, v = coe_to_rv(elements, constants.GM_EARTH)
elements_back = rv_to_coe(r, v, constants.GM_EARTH)
assert abs(elements_back.i - i) < 1e-10, (
f"Nearly equatorial inclination: got {np.rad2deg(elements_back.i):.6f}°, "
f"expected {np.rad2deg(i):.6f}°"
)
def test_polar_orbit(self):
a = constants.R_EARTH + 800e3
e = 0.001
i = np.deg2rad(90.0)
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)
period = 2.0 * np.pi * np.sqrt(a**3 / constants.GM_EARTH)
r_final, v_final = propagate_state_keplerian(r0, v0, period, constants.GM_EARTH)
closure_error = np.linalg.norm(r_final - r0)
assert (
closure_error < 1.0
), f"Polar orbit closure error: {closure_error:.3f} m after one period"
def test_retrograde_orbit(self):
a = constants.R_EARTH + 700e3
e = 0.01
i = np.deg2rad(120.0)
elements = OrbitalElements(
a=a, e=e, i=i, raan=np.deg2rad(50.0), argp=np.deg2rad(30.0), nu=np.deg2rad(0.0)
)
r0, v0 = coe_to_rv(elements, constants.GM_EARTH)
dt = 5.0 * 86400.0
r_final, v_final = propagate_j2_dopri5(
r0, v0, dt, constants.GM_EARTH, constants.J2_EARTH, constants.R_EARTH, tol=1e-9
)
elements_final = rv_to_coe(r_final, v_final, constants.GM_EARTH)
raan_drift = elements_final.raan - elements.raan
assert (
raan_drift > 0
), f"Retrograde orbit J2 RAAN drift should be positive, got {np.rad2deg(raan_drift):.3f}°"
def test_parabolic_escape_trajectory(self):
r_periapsis = constants.R_EARTH + 200e3
v_escape = np.sqrt(2.0 * constants.GM_EARTH / r_periapsis)
r = np.array([r_periapsis, 0.0, 0.0])
v = np.array([0.0, v_escape, 0.0])
epsilon = np.dot(v, v) / 2.0 - constants.GM_EARTH / np.linalg.norm(r)
assert (
abs(epsilon) < 1.0
), f"Parabolic trajectory specific energy should be ≈0, got {epsilon:.2e} J/kg"
v_magnitude = np.linalg.norm(v)
expected_v_escape = np.sqrt(2.0 * constants.GM_EARTH / r_periapsis)
assert (
abs(v_magnitude - expected_v_escape) < 0.1
), f"Escape velocity: got {v_magnitude/1e3:.3f} km/s, expected {expected_v_escape/1e3:.3f} km/s"
class TestAnomalyConversionRegression:
def test_mean_to_true_anomaly_known_values(self):
test_cases = [
(0.0, 0.0, 0.0), (0.0, 90.0, 90.0), (0.5, 0.0, 0.0), (0.5, 180.0, 180.0), (0.3, 90.0, None), (0.7, 120.0, None), ]
for e, M_deg, expected_nu_deg in test_cases:
M = np.deg2rad(M_deg)
nu = mean_to_true_anomaly(M, e)
nu_deg = np.rad2deg(nu)
if expected_nu_deg is not None:
error = abs(nu_deg - expected_nu_deg)
assert error < 0.5, (
f"Mean to true anomaly (e={e}, M={M_deg}°): "
f"got ν={nu_deg:.2f}°, expected {expected_nu_deg:.2f}°"
)
else:
assert 0 <= nu_deg <= 360, (
f"Mean to true anomaly (e={e}, M={M_deg}°): "
f"got ν={nu_deg:.2f}°, expected 0-360° range"
)
def test_true_to_mean_anomaly_roundtrip(self):
eccentricities = [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]
true_anomalies_deg = [0.0, 30.0, 60.0, 90.0, 120.0, 150.0, 180.0]
for e in eccentricities:
for nu_deg in true_anomalies_deg:
nu = np.deg2rad(nu_deg)
M = true_to_mean_anomaly(nu, e)
nu_back = mean_to_true_anomaly(M, e)
error = abs(nu_back - nu)
assert error < 1e-10, (
f"Anomaly roundtrip error (e={e}, ν={nu_deg}°): "
f"Δν = {np.rad2deg(error):.2e}°"
)
@pytest.fixture(scope="session", autouse=True)
def regression_test_summary(request):
def print_summary():
print("\n" + "=" * 70)
print("REGRESSION TEST SUITE SUMMARY")
print("=" * 70)
print("Test Categories:")
print(" 1. Curtis Textbook Examples: 4 tests")
print(" 2. Interplanetary Transfers: 2 tests")
print(" 3. Historical Missions: 3 tests")
print(" 4. Perturbation Models: 3 tests")
print(" 5. Special Orbital Cases: 6 tests")
print(" 6. Anomaly Conversions: 2 tests")
print("-" * 70)
print("Total Regression Tests: 20")
print("=" * 70)
request.addfinalizer(print_summary)