import numpy as np
import pytest
from astropy import units as u
from astrora._core import Duration, Epoch
from astrora.bodies import Earth, Mars, Sun
from astrora.twobody import Orbit
class TestOrbitCreation:
def test_from_vectors_basic(self):
r = np.array([7000e3, 0, 0]) v = np.array([0, 7546, 0])
orbit = Orbit.from_vectors(Earth, r, v)
assert orbit.attractor.name == "Earth"
assert np.allclose(orbit.r.to(u.m).value, r, rtol=1e-10)
assert np.allclose(orbit.v.to(u.m / u.s).value, v, rtol=1e-10)
def test_from_vectors_with_epoch(self):
r = np.array([7000e3, 0, 0])
v = np.array([0, 7546, 0])
epoch = Epoch.from_midnight_utc(2023, 6, 15)
orbit = Orbit.from_vectors(Earth, r, v, epoch)
assert orbit.epoch == epoch
def test_from_vectors_default_epoch(self):
r = np.array([7000e3, 0, 0])
v = np.array([0, 7546, 0])
orbit = Orbit.from_vectors(Earth, r, v)
j2000 = Epoch.j2000_epoch()
assert orbit.epoch == j2000
def test_from_classical_circular(self):
a = 7000e3 ecc = 0.0 inc = np.deg2rad(28.5)
raan = 0.0
argp = 0.0
nu = 0.0
orbit = Orbit.from_classical(Earth, a, ecc, inc, raan, argp, nu)
assert orbit.attractor.name == "Earth"
assert np.isclose(orbit.a.to(u.m).value, a, rtol=1e-10)
assert np.isclose(orbit.ecc.value, ecc, atol=1e-10)
assert np.isclose(orbit.inc.to(u.rad).value, inc, rtol=1e-10)
def test_from_classical_elliptical(self):
a = 10000e3
ecc = 0.3
inc = np.deg2rad(45)
raan = np.deg2rad(30)
argp = np.deg2rad(60)
nu = np.deg2rad(90)
orbit = Orbit.from_classical(Earth, a, ecc, inc, raan, argp, nu)
assert np.isclose(orbit.a.to(u.m).value, a, rtol=1e-8)
assert np.isclose(orbit.ecc.value, ecc, rtol=1e-8)
assert np.isclose(orbit.inc.to(u.rad).value, inc, rtol=1e-8)
assert np.isclose(orbit.raan.to(u.rad).value, raan, rtol=1e-8)
assert np.isclose(orbit.argp.to(u.rad).value, argp, rtol=1e-8)
assert np.isclose(orbit.nu.to(u.rad).value, nu, rtol=1e-8)
def test_from_classical_different_attractors(self):
a = 1.5e11 ecc = 0.1
inc = 0.0
raan = 0.0
argp = 0.0
nu = 0.0
orbit_sun = Orbit.from_classical(Sun, a, ecc, inc, raan, argp, nu)
orbit_earth = Orbit.from_classical(Earth, a, ecc, inc, raan, argp, nu)
assert orbit_sun.attractor.name == "Sun"
assert orbit_earth.attractor.name == "Earth"
assert not np.allclose(orbit_sun.v.value, orbit_earth.v.value)
def test_invalid_position_shape(self):
r = np.array([7000e3, 0]) v = np.array([0, 7546, 0])
with pytest.raises(ValueError, match="3-element"):
Orbit.from_vectors(Earth, r, v)
def test_invalid_velocity_shape(self):
r = np.array([7000e3, 0, 0])
v = np.array([0, 7546])
with pytest.raises(ValueError, match="3-element"):
Orbit.from_vectors(Earth, r, v)
class TestOrbitProperties:
@pytest.fixture
def circular_orbit(self):
r = np.array([7000e3, 0, 0])
v = np.array([0, 7546, 0])
return Orbit.from_vectors(Earth, r, v)
@pytest.fixture
def elliptical_orbit(self):
return Orbit.from_classical(
Earth,
a=24000e3,
ecc=0.7,
inc=np.deg2rad(7),
raan=0.0,
argp=0.0,
nu=0.0,
)
def test_position_vector(self, circular_orbit):
r = circular_orbit.r
assert isinstance(r, u.Quantity)
assert r.value.shape == (3,)
assert np.isclose(np.linalg.norm(r.to(u.m).value), 7000e3, rtol=1e-10)
def test_velocity_vector(self, circular_orbit):
v = circular_orbit.v
assert isinstance(v, u.Quantity)
assert v.value.shape == (3,)
assert np.isclose(np.linalg.norm(v.to(u.m / u.s).value), 7546, rtol=1e-3)
def test_semi_major_axis(self, circular_orbit):
a = circular_orbit.a
assert isinstance(a, u.Quantity)
assert np.isclose(a.to(u.m).value, 7000e3, rtol=1e-3)
def test_eccentricity_circular(self, circular_orbit):
ecc = circular_orbit.ecc
assert isinstance(ecc, u.Quantity)
assert np.isclose(ecc.value, 0.0, atol=1e-4)
def test_eccentricity_elliptical(self, elliptical_orbit):
ecc = elliptical_orbit.ecc
assert np.isclose(ecc, 0.7, rtol=1e-6)
def test_inclination(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.0,
inc=np.deg2rad(51.6), raan=0.0,
argp=0.0,
nu=0.0,
)
assert np.isclose(orbit.inc.to(u.rad).value, np.deg2rad(51.6), rtol=1e-6)
def test_raan(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.0,
inc=np.deg2rad(28.5),
raan=np.deg2rad(45),
argp=0.0,
nu=0.0,
)
assert np.isclose(orbit.raan.to(u.rad).value, np.deg2rad(45), rtol=1e-6)
def test_argp(self):
orbit = Orbit.from_classical(
Earth,
a=10000e3,
ecc=0.3,
inc=0.0,
raan=0.0,
argp=np.deg2rad(90),
nu=0.0,
)
assert np.isclose(orbit.argp.to(u.rad).value, np.deg2rad(90), rtol=1e-6)
def test_true_anomaly(self):
orbit = Orbit.from_classical(
Earth,
a=10000e3,
ecc=0.3,
inc=0.0,
raan=0.0,
argp=0.0,
nu=np.deg2rad(120),
)
assert np.isclose(orbit.nu.to(u.rad).value, np.deg2rad(120), rtol=1e-6)
def test_period(self, circular_orbit):
period = circular_orbit.period
expected_period = 2 * np.pi * np.sqrt(7000e3**3 / Earth.mu)
assert np.isclose(period.to(u.s).value, expected_period, rtol=1e-4)
assert period.to(u.s).value > 0
assert period.to(u.s).value < 10000
def test_mean_motion(self, circular_orbit):
n = circular_orbit.n
period_s = circular_orbit.period.to(u.s).value
expected_n = (2 * np.pi / period_s) * u.rad / u.s
assert np.isclose(n.to(u.rad / u.s).value, expected_n.to(u.rad / u.s).value, rtol=1e-10)
def test_specific_energy_elliptical(self, elliptical_orbit):
energy = elliptical_orbit.energy
assert energy < 0
def test_specific_energy_circular(self, circular_orbit):
energy = circular_orbit.energy
a_m = circular_orbit.a.to(u.m).value
expected_energy = (-Earth.mu / (2 * a_m)) * u.m**2 / u.s**2
assert np.isclose(energy.to(u.J / u.kg).value, expected_energy.to(u.J / u.kg).value, rtol=1e-6)
def test_semi_latus_rectum(self, elliptical_orbit):
p = elliptical_orbit.p
a_val = elliptical_orbit.a.to(u.m).value
ecc_val = elliptical_orbit.ecc.value
expected_p_val = a_val * (1 - ecc_val**2)
assert np.isclose(p.to(u.m).value, expected_p_val, rtol=1e-10)
def test_periapsis_distance(self, elliptical_orbit):
r_p = elliptical_orbit.r_p
a_val = elliptical_orbit.a.to(u.m).value
ecc_val = elliptical_orbit.ecc.value
expected_r_p_val = a_val * (1 - ecc_val)
assert np.isclose(r_p.to(u.m).value, expected_r_p_val, rtol=1e-10)
def test_apoapsis_distance(self, elliptical_orbit):
r_a = elliptical_orbit.r_a
a_val = elliptical_orbit.a.to(u.m).value
ecc_val = elliptical_orbit.ecc.value
expected_r_a_val = a_val * (1 + ecc_val)
assert np.isclose(r_a.to(u.m).value, expected_r_a_val, rtol=1e-10)
def test_property_caching(self, circular_orbit):
a1 = circular_orbit.a
ecc1 = circular_orbit.ecc
a2 = circular_orbit.a
ecc2 = circular_orbit.ecc
assert a1 == a2
assert ecc1 == ecc2
class TestOrbitPropagation:
@pytest.fixture
def iss_orbit(self):
return Orbit.from_classical(
Earth,
a=6800e3, ecc=0.0005, inc=np.deg2rad(51.6),
raan=0.0,
argp=0.0,
nu=0.0,
)
def test_propagate_zero_time(self, iss_orbit):
dt = 0.0
future = iss_orbit.propagate(dt)
assert np.allclose(future.r.value, iss_orbit.r.value, rtol=1e-10)
assert np.allclose(future.v.value, iss_orbit.v.value, rtol=1e-10)
def test_propagate_one_period(self, iss_orbit):
period = iss_orbit.period
future = iss_orbit.propagate(period)
assert np.allclose(future.r.value, iss_orbit.r.value, rtol=1e-6)
assert np.allclose(future.v.value, iss_orbit.v.value, rtol=1e-6)
def test_propagate_with_duration(self, iss_orbit):
dt = Duration.from_hrs(1)
future = iss_orbit.propagate(dt)
assert not np.allclose(future.r.value, iss_orbit.r.value, rtol=1e-3)
assert np.isclose(future.a.to(u.m).value, iss_orbit.a.to(u.m).value, rtol=1e-6)
def test_propagate_backward(self, iss_orbit):
dt = -3600 past = iss_orbit.propagate(dt)
future = past.propagate(3600)
assert np.allclose(future.r.value, iss_orbit.r.value, rtol=1e-5, atol=1e-3)
def test_propagate_updates_epoch(self, iss_orbit):
dt = Duration.from_hrs(2)
future = iss_orbit.propagate(dt)
expected_epoch = iss_orbit.epoch + dt
assert future.epoch == expected_epoch
def test_propagate_preserves_attractor(self, iss_orbit):
dt = 3600
future = iss_orbit.propagate(dt)
assert future.attractor.name == iss_orbit.attractor.name
assert future.attractor.mu == iss_orbit.attractor.mu
def test_propagate_quarter_orbit(self, iss_orbit):
dt = iss_orbit.period / 4
future = iss_orbit.propagate(dt)
delta_nu_rad = (future.nu - iss_orbit.nu).to(u.rad).value
assert np.isclose(delta_nu_rad, np.pi / 2, rtol=0.1)
class TestOrbitSampling:
@pytest.fixture
def circular_orbit(self):
return Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.0,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
def test_sample_basic(self, circular_orbit):
times = np.linspace(0, circular_orbit.period, 10)
positions, velocities = circular_orbit.sample(times)
assert positions.shape == (10, 3)
assert velocities.shape == (10, 3)
def test_sample_one_period(self, circular_orbit):
times = np.linspace(0, circular_orbit.period, 100)
positions, velocities = circular_orbit.sample(times)
assert np.allclose(positions[0], positions[-1], rtol=1e-6)
assert np.allclose(velocities[0], velocities[-1], rtol=1e-6)
def test_sample_preserves_altitude(self, circular_orbit):
times = np.linspace(0, circular_orbit.period, 50)
positions, velocities = circular_orbit.sample(times)
radii = np.linalg.norm(positions, axis=1)
assert np.allclose(radii, radii[0], rtol=1e-6)
def test_sample_list_input(self, circular_orbit):
times = [0, 1000, 2000, 3000]
positions, velocities = circular_orbit.sample(times)
assert positions.shape == (4, 3)
assert velocities.shape == (4, 3)
class TestOrbitManeuvers:
@pytest.fixture
def leo_orbit(self):
return Orbit.from_classical(
Earth,
a=6800e3,
ecc=0.0,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
def test_apply_prograde_burn(self, leo_orbit):
v_hat = leo_orbit.v.value / np.linalg.norm(leo_orbit.v.value)
delta_v = 100 * v_hat
new_orbit = leo_orbit.apply_maneuver(delta_v)
assert np.linalg.norm(new_orbit.v.value) > np.linalg.norm(leo_orbit.v.value)
assert np.allclose(new_orbit.r.value, leo_orbit.r.value, rtol=1e-10)
assert new_orbit.a > leo_orbit.a
def test_apply_retrograde_burn(self, leo_orbit):
v_hat = leo_orbit.v.value / np.linalg.norm(leo_orbit.v.value)
delta_v = -100 * v_hat
new_orbit = leo_orbit.apply_maneuver(delta_v)
assert np.linalg.norm(new_orbit.v.value) < np.linalg.norm(leo_orbit.v.value)
assert new_orbit.a < leo_orbit.a
def test_apply_normal_burn(self, leo_orbit):
delta_v = np.array([0, 0, 50])
new_orbit = leo_orbit.apply_maneuver(delta_v)
inc_rad = new_orbit.inc.to(u.rad).value
assert inc_rad > 0
assert inc_rad < np.pi / 2
def test_apply_zero_maneuver(self, leo_orbit):
delta_v = np.array([0, 0, 0])
new_orbit = leo_orbit.apply_maneuver(delta_v)
assert np.allclose(new_orbit.r.value, leo_orbit.r.value, rtol=1e-10)
assert np.allclose(new_orbit.v.value, leo_orbit.v.value, rtol=1e-10)
def test_maneuver_invalid_shape(self, leo_orbit):
delta_v = np.array([100, 0])
with pytest.raises(ValueError, match="3-element array"):
leo_orbit.apply_maneuver(delta_v)
class TestOrbitRepresentation:
def test_repr_basic(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.1,
inc=np.deg2rad(28.5),
raan=0.0,
argp=0.0,
nu=0.0,
)
repr_str = repr(orbit)
assert "Orbit" in repr_str
assert "r =" in repr_str
assert "v =" in repr_str
assert "a =" in repr_str
assert "km" in repr_str
assert "km/s" in repr_str
class TestOrbitEdgeCases:
def test_equatorial_orbit(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.1,
inc=0.0, raan=0.0,
argp=0.0,
nu=0.0,
)
assert np.isclose(orbit.inc.to(u.rad).value, 0.0, atol=1e-8)
def test_polar_orbit(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.0,
inc=np.pi / 2, raan=0.0,
argp=0.0,
nu=0.0,
)
assert np.isclose(orbit.inc.to(u.rad).value, np.pi / 2, rtol=1e-8)
def test_high_eccentricity(self):
orbit = Orbit.from_classical(
Earth,
a=20000e3,
ecc=0.9, inc=np.deg2rad(63.4), raan=0.0,
argp=np.deg2rad(270),
nu=0.0,
)
assert np.isclose(orbit.ecc.value, 0.9, rtol=1e-6)
assert orbit.r_p.value < orbit.r_a.value / 10
def test_very_high_orbit(self):
orbit = Orbit.from_classical(
Earth,
a=42164e3, ecc=0.0,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
period_hours = orbit.period.to(u.h).value
assert np.isclose(period_hours, 24.0, rtol=0.01)
def test_different_attractors(self):
orbit_mars = Orbit.from_classical(
Mars,
a=10000e3,
ecc=0.1,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
orbit_sun = Orbit.from_classical(
Sun,
a=1.5e11, ecc=0.017, inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
assert orbit_mars.attractor.name == "Mars"
assert orbit_sun.attractor.name == "Sun"
assert orbit_sun.period > orbit_mars.period
if __name__ == "__main__":
pytest.main([__file__, "-v"])