import numpy as np
import pytest
from astrora._core import (
Duration,
OrbitalElements,
constants,
propagate_keplerian,
propagate_keplerian_duration,
propagate_lagrange,
propagate_state_keplerian,
)
GM_EARTH = constants.GM_EARTH
class TestKeplerianPropagator:
def test_propagate_circular_orbit_quarter_period(self):
a = 7000e3 e = 0.0 i = 0.0 raan = 0.0
argp = 0.0
nu0 = 0.0
elements = OrbitalElements(a, e, i, raan, argp, nu0)
period = elements.orbital_period(GM_EARTH)
dt = period / 4.0
new_elements = propagate_keplerian(elements, dt, GM_EARTH)
assert new_elements.nu == pytest.approx(np.pi / 2.0, abs=1e-6)
assert new_elements.a == pytest.approx(a, rel=1e-6)
assert new_elements.e == pytest.approx(e, abs=1e-8)
assert new_elements.i == pytest.approx(i, abs=1e-8)
assert new_elements.raan == pytest.approx(raan, abs=1e-8)
assert new_elements.argp == pytest.approx(argp, abs=1e-8)
def test_propagate_full_orbit(self):
a = 8000e3
e = 0.1
i = 0.0
raan = 0.0
argp = 0.0
nu0 = 0.0
elements = OrbitalElements(a, e, i, raan, argp, nu0)
period = elements.orbital_period(GM_EARTH)
new_elements = propagate_keplerian(elements, period, GM_EARTH)
nu_diff = abs(new_elements.nu - nu0)
assert (nu_diff < 1e-6) or (abs(2.0 * np.pi - nu_diff) < 1e-6)
assert new_elements.a == pytest.approx(a, rel=1e-6)
assert new_elements.e == pytest.approx(e, abs=1e-6)
def test_propagate_half_orbit(self):
a = 7500e3
e = 0.05
i = 0.0
raan = 0.0
argp = 0.0
nu0 = 0.0
elements = OrbitalElements(a, e, i, raan, argp, nu0)
period = elements.orbital_period(GM_EARTH)
new_elements = propagate_keplerian(elements, period / 2.0, GM_EARTH)
assert new_elements.nu == pytest.approx(np.pi, abs=1e-5)
def test_propagate_rejects_hyperbolic(self):
elements = OrbitalElements(8000e3, 1.5, 0.0, 0.0, 0.0, 0.0)
with pytest.raises(ValueError, match="hyperbolic"):
propagate_keplerian(elements, 3600.0, GM_EARTH)
def test_propagate_rejects_parabolic(self):
elements = OrbitalElements(8000e3, 1.0, 0.0, 0.0, 0.0, 0.0)
with pytest.raises(ValueError, match="parabolic"):
propagate_keplerian(elements, 3600.0, GM_EARTH)
def test_propagate_with_duration(self):
elements = OrbitalElements(7000e3, 0.01, 0.0, 0.0, 0.0, 0.0)
duration = Duration(3600.0)
new_elements = propagate_keplerian_duration(elements, duration, GM_EARTH)
assert new_elements.a == pytest.approx(elements.a, rel=1e-6)
assert new_elements.e == pytest.approx(elements.e, abs=1e-8)
assert new_elements.nu != elements.nu
class TestStateVectorPropagator:
def test_propagate_state_circular(self):
r0 = np.array([7000e3, 0.0, 0.0])
v_circ = np.sqrt(GM_EARTH / 7000e3)
v0 = np.array([0.0, v_circ, 0.0])
dt = 3600.0
r, v = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
r0_mag = np.linalg.norm(r0)
r_mag = np.linalg.norm(r)
assert r_mag == pytest.approx(r0_mag, rel=1e-6)
v0_mag = np.linalg.norm(v0)
v_mag = np.linalg.norm(v)
assert v_mag == pytest.approx(v0_mag, rel=1e-6)
def test_propagate_state_energy_conservation(self):
r0 = np.array([7000e3, 0.0, 0.0])
v0 = np.array([0.0, 8000.0, 0.0])
v0_mag_sq = np.dot(v0, v0)
r0_mag = np.linalg.norm(r0)
energy0 = 0.5 * v0_mag_sq - GM_EARTH / r0_mag
dt = 5000.0
r, v = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
v_mag_sq = np.dot(v, v)
r_mag = np.linalg.norm(r)
energy = 0.5 * v_mag_sq - GM_EARTH / r_mag
assert energy == pytest.approx(energy0, rel=1e-6)
def test_propagate_state_angular_momentum_conservation(self):
r0 = np.array([7000e3, 0.0, 0.0])
v0 = np.array([0.0, 8000.0, 0.0])
h0 = np.cross(r0, v0)
dt = 5000.0
r, v = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
h = np.cross(r, v)
np.testing.assert_allclose(h, h0, rtol=1e-6)
def test_propagate_backward_in_time(self):
r0 = np.array([7000e3, 0.0, 0.0])
v_circ = np.sqrt(GM_EARTH / 7000e3)
v0 = np.array([0.0, v_circ, 0.0])
dt = 3600.0
r_fwd, v_fwd = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
r_back, v_back = propagate_state_keplerian(r_fwd, v_fwd, -dt, GM_EARTH)
np.testing.assert_allclose(r_back, r0, atol=1e-3)
np.testing.assert_allclose(v_back, v0, atol=1e-3)
def test_propagate_inclined_orbit(self):
r0 = np.array([7000e3, 0.0, 0.0])
v_circ = np.sqrt(GM_EARTH / 7000e3)
angle = np.pi / 4.0 v0 = np.array([0.0, v_circ * np.cos(angle), v_circ * np.sin(angle)])
dt = 2000.0
r, v = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
energy0 = 0.5 * np.dot(v0, v0) - GM_EARTH / np.linalg.norm(r0)
energy = 0.5 * np.dot(v, v) - GM_EARTH / np.linalg.norm(r)
assert energy == pytest.approx(energy0, rel=1e-6)
h0 = np.cross(r0, v0)
h = np.cross(r, v)
np.testing.assert_allclose(h, h0, rtol=1e-6)
class TestLagrangePropagator:
def test_lagrange_vs_mean_anomaly(self):
r0 = np.array([7000e3, 0.0, 0.0])
v_circ = np.sqrt(GM_EARTH / 7000e3)
v0 = np.array([0.0, v_circ, 0.0])
dt = 3600.0
r1, v1 = propagate_state_keplerian(r0, v0, dt, GM_EARTH)
r2, v2 = propagate_lagrange(r0, v0, dt, GM_EARTH)
np.testing.assert_allclose(r1, r2, rtol=1e-6, atol=1.0)
np.testing.assert_allclose(v1, v2, rtol=1e-6, atol=1e-3)
def test_lagrange_elliptical_orbit(self):
r0 = np.array([7000e3, 0.0, 0.0])
v0 = np.array([0.0, 8500.0, 0.0]) dt = 4000.0
r, v = propagate_lagrange(r0, v0, dt, GM_EARTH)
energy0 = 0.5 * np.dot(v0, v0) - GM_EARTH / np.linalg.norm(r0)
energy = 0.5 * np.dot(v, v) - GM_EARTH / np.linalg.norm(r)
assert energy == pytest.approx(energy0, rel=1e-6)
h0 = np.cross(r0, v0)
h = np.cross(r, v)
np.testing.assert_allclose(h, h0, rtol=1e-6)
def test_lagrange_multiple_propagations(self):
r0 = np.array([7000e3, 0.0, 0.0])
v_circ = np.sqrt(GM_EARTH / 7000e3)
v0 = np.array([0.0, v_circ, 0.0])
dt_step = 1000.0 r, v = r0, v0
for _ in range(5):
r, v = propagate_lagrange(r, v, dt_step, GM_EARTH)
r_single, v_single = propagate_lagrange(r0, v0, 5 * dt_step, GM_EARTH)
np.testing.assert_allclose(r, r_single, rtol=1e-5, atol=10.0)
np.testing.assert_allclose(v, v_single, rtol=1e-5, atol=0.1)
class TestInputValidation:
def test_state_vector_wrong_size(self):
r0 = np.array([7000e3, 0.0]) v0 = np.array([0.0, 7546.0, 0.0])
with pytest.raises(ValueError, match="exactly 3 components"):
propagate_state_keplerian(r0, v0, 3600.0, GM_EARTH)
def test_negative_semi_major_axis(self):
elements = OrbitalElements(-7000e3, 0.1, 0.0, 0.0, 0.0, 0.0)
with pytest.raises(ValueError, match="positive"):
propagate_keplerian(elements, 3600.0, GM_EARTH)
class TestRealWorldScenarios:
def test_iss_like_orbit(self):
altitude = 400e3
r_earth = 6371e3
a = r_earth + altitude
elements = OrbitalElements(a, 0.0005, np.deg2rad(51.6), 0.0, 0.0, 0.0)
period = elements.orbital_period(GM_EARTH)
new_elements = propagate_keplerian(elements, period, GM_EARTH)
nu_diff = abs(new_elements.nu - elements.nu)
assert (nu_diff < 1e-5) or (abs(2.0 * np.pi - nu_diff) < 1e-5)
assert period / 60.0 == pytest.approx(92.7, abs=1.0)
def test_geostationary_orbit(self):
altitude = 35786e3
r_earth = 6371e3
a = r_earth + altitude
elements = OrbitalElements(a, 0.0, 0.0, 0.0, 0.0, 0.0)
period = elements.orbital_period(GM_EARTH)
assert period / 3600.0 == pytest.approx(23.93, abs=0.1)
new_elements = propagate_keplerian(elements, period, GM_EARTH)
assert new_elements.a == pytest.approx(a, rel=1e-6)
def test_molniya_orbit(self):
a = 26554e3 e = 0.74 i = np.deg2rad(63.4)
elements = OrbitalElements(a, e, i, 0.0, 0.0, 0.0)
period = elements.orbital_period(GM_EARTH)
assert period / 3600.0 == pytest.approx(12.0, abs=0.5)
new_elements = propagate_keplerian(elements, period / 2.0, GM_EARTH)
assert new_elements.nu == pytest.approx(np.pi, abs=1e-5)
if __name__ == "__main__":
pytest.main([__file__, "-v"])