import numpy as np
import pytest
from astropy import units as u
from astropy.time import Time
from astrora.bodies import Earth, Jupiter, Mars
from astrora.twobody import Orbit
class TestCircularOrbit:
def test_circular_basic(self):
orbit = Orbit.circular(Earth, alt=700e3)
assert orbit.ecc.value < 1e-10, "Orbit should be circular (ecc ≈ 0)"
expected_a = Earth.R + 700e3
np.testing.assert_allclose(orbit.a.to(u.m).value, expected_a, rtol=1e-6)
def test_circular_with_units(self):
orbit = Orbit.circular(Earth, alt=400 * u.km, inc=51.6 * u.deg)
assert orbit.ecc.value < 1e-10
np.testing.assert_allclose(orbit.inc.to(u.deg).value, 51.6, rtol=1e-6)
expected_a = Earth.R + 400e3
np.testing.assert_allclose(orbit.a.to(u.m).value, expected_a, rtol=1e-6)
def test_circular_iss_like(self):
orbit = Orbit.circular(Earth, alt=400 * u.km, inc=51.6 * u.deg)
period_minutes = orbit.period.to(u.minute).value
assert 92 < period_minutes < 94, f"ISS period should be ~93 min, got {period_minutes:.1f}"
def test_circular_equatorial(self):
orbit = Orbit.circular(Earth, alt=700e3, inc=0.0)
assert orbit.ecc.value < 1e-10
assert orbit.inc.to(u.deg).value < 1e-6, "Should be equatorial"
def test_circular_polar(self):
orbit = Orbit.circular(Earth, alt=800e3, inc=90 * u.deg)
assert orbit.ecc.value < 1e-10
np.testing.assert_allclose(orbit.inc.to(u.deg).value, 90.0, rtol=1e-6)
def test_circular_with_raan_arglat(self):
orbit = Orbit.circular(
Earth,
alt=500e3,
inc=28.5 * u.deg,
raan=45 * u.deg,
arglat=30 * u.deg,
)
assert orbit.ecc.value < 1e-10
np.testing.assert_allclose(orbit.raan.to(u.deg).value, 45.0, rtol=1e-3)
def test_circular_with_epoch(self):
epoch = Time("2024-01-01 12:00:00", scale="utc")
orbit = Orbit.circular(Earth, alt=700e3, epoch=epoch)
assert orbit.ecc.value < 1e-10
class TestGeostationary:
def test_geostationary_basic(self):
orbit = Orbit.geostationary()
assert orbit.ecc.value < 1e-6, "GEO should be circular"
assert orbit.inc.to(u.deg).value < 1e-3, "GEO should be equatorial"
altitude_km = orbit.a.to(u.km).value - Earth.R / 1000
np.testing.assert_allclose(altitude_km, 35786, rtol=0.01)
def test_geostationary_period(self):
orbit = Orbit.geostationary()
period_hours = orbit.period.to(u.hour).value
sidereal_day_hours = 23.9344696 np.testing.assert_allclose(period_hours, sidereal_day_hours, rtol=0.01)
def test_geostationary_default_attractor(self):
orbit = Orbit.geostationary()
assert orbit.attractor.name == "Earth"
def test_geostationary_with_position(self):
orbit = Orbit.geostationary(arglat=45 * u.deg)
assert orbit.ecc.value < 1e-6
np.testing.assert_allclose(orbit.nu.to(u.deg).value, 45.0, rtol=1e-2)
class TestSynchronous:
def test_synchronous_earth_default(self):
orbit = Orbit.synchronous(Earth)
period_hours = orbit.period.to(u.hour).value
sidereal_day_hours = 23.9344696
np.testing.assert_allclose(period_hours, sidereal_day_hours, rtol=0.01)
def test_synchronous_mars(self):
orbit = Orbit.synchronous(Mars)
period_hours = orbit.period.to(u.hour).value
mars_sol_hours = 24.6229
np.testing.assert_allclose(period_hours, mars_sol_hours, rtol=0.01)
assert orbit.ecc.value < 1e-6
assert orbit.inc.to(u.deg).value < 1e-3
def test_synchronous_jupiter(self):
orbit = Orbit.synchronous(Jupiter)
period_hours = orbit.period.to(u.hour).value
jupiter_day_hours = 9.925
np.testing.assert_allclose(period_hours, jupiter_day_hours, rtol=0.01)
def test_synchronous_semi_period(self):
orbit = Orbit.synchronous(Earth, period_mul=2.0)
period_hours = orbit.period.to(u.hour).value
expected_hours = 2 * 23.9344696
np.testing.assert_allclose(period_hours, expected_hours, rtol=0.01)
def test_synchronous_with_eccentricity(self):
orbit = Orbit.synchronous(Earth, ecc=0.1)
np.testing.assert_allclose(orbit.ecc.value, 0.1, rtol=1e-3)
period_hours = orbit.period.to(u.hour).value
sidereal_day_hours = 23.9344696
np.testing.assert_allclose(period_hours, sidereal_day_hours, rtol=0.01)
def test_synchronous_with_inclination(self):
orbit = Orbit.synchronous(Earth, inc=28.5 * u.deg)
np.testing.assert_allclose(orbit.inc.to(u.deg).value, 28.5, rtol=1e-3)
def test_synchronous_no_rotation_period_error(self):
from astrora.bodies import Body
test_body = Body(name="TestBody", mu=3.986e14, R=6.371e6)
with pytest.raises(ValueError, match="does not have a defined rotational period"):
Orbit.synchronous(test_body)
class TestParabolic:
def test_parabolic_basic(self):
p = 7000e3 orbit = Orbit.parabolic(
Earth,
p=p,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
assert orbit.ecc.value > 0.999, "Eccentricity should be very close to 1 (parabolic)"
def test_parabolic_with_units(self):
orbit = Orbit.parabolic(
Earth,
p=6678 * u.km,
inc=28.5 * u.deg,
raan=0 * u.deg,
argp=0 * u.deg,
nu=0 * u.deg,
)
assert orbit.ecc.value > 0.999, "Eccentricity should be very close to 1 (parabolic)"
np.testing.assert_allclose(orbit.inc.to(u.deg).value, 28.5, rtol=1e-3)
def test_parabolic_energy_near_zero(self):
p = 7000e3
orbit = Orbit.parabolic(
Earth,
p=p,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
energy = orbit.energy.to(u.MJ / u.kg).value
assert (
abs(energy) < 0.5
), f"Nearly-parabolic orbit energy should be ~0, got {energy:.3f} MJ/kg"
def test_parabolic_escape_trajectory(self):
p = 7000e3
orbit = Orbit.parabolic(
Earth,
p=p,
inc=0.0,
raan=0.0,
argp=0.0,
nu=0.0,
)
assert orbit.ecc.value >= 0.999, "Should be parabolic (e ≈ 1)"
class TestHelperIntegration:
def test_circular_vs_classical_equivalence(self):
alt = 700e3
inc = np.deg2rad(51.6)
orbit1 = Orbit.circular(Earth, alt=alt, inc=inc)
a = Earth.R + alt
orbit2 = Orbit.from_classical(
Earth,
a=a,
ecc=0.0,
inc=inc,
raan=0.0,
argp=0.0,
nu=0.0,
)
np.testing.assert_allclose(orbit1.r.to(u.m).value, orbit2.r.to(u.m).value, rtol=1e-6)
np.testing.assert_allclose(
orbit1.v.to(u.m / u.s).value, orbit2.v.to(u.m / u.s).value, rtol=1e-6
)
def test_geostationary_altitude_consistency(self):
orbit = Orbit.geostationary()
a = orbit.a.to(u.m).value
mu = Earth.mu
period_computed = 2 * np.pi * np.sqrt(a**3 / mu)
period_actual = orbit.period.to(u.s).value
np.testing.assert_allclose(period_computed, period_actual, rtol=1e-6)
def test_all_helpers_return_orbit_instances(self):
orbit1 = Orbit.circular(Earth, alt=700e3)
orbit2 = Orbit.geostationary()
orbit3 = Orbit.synchronous(Mars)
orbit4 = Orbit.parabolic(Earth, p=7000e3, inc=0, raan=0, argp=0, nu=0)
assert isinstance(orbit1, Orbit)
assert isinstance(orbit2, Orbit)
assert isinstance(orbit3, Orbit)
assert isinstance(orbit4, Orbit)