import numpy as np
import pytest
from astropy import units as u
from astrora.bodies import Earth
from astrora.twobody import Orbit
class TestOrbitCreationWithUnits:
def test_from_vectors_with_units(self):
r = [7000, 0, 0] << u.km
v = [0, 7.546, 0] << u.km / u.s
orbit = Orbit.from_vectors(Earth, r, v)
assert orbit is not None
assert orbit.attractor == Earth
r_result = orbit.r.to(u.km).value
np.testing.assert_allclose(r_result, [7000, 0, 0], rtol=1e-10)
def test_from_vectors_with_raw_arrays(self):
r = np.array([7000e3, 0, 0]) v = np.array([0, 7546, 0])
orbit = Orbit.from_vectors(Earth, r, v)
assert orbit is not None
r_result = orbit.r.to(u.m).value
np.testing.assert_allclose(r_result, [7000e3, 0, 0], rtol=1e-10)
def test_from_vectors_unit_conversion(self):
r = [1, 0, 0] << u.AU
v = [0, 30, 0] << u.km / u.s
orbit = Orbit.from_vectors(Earth, r, v)
r_au = orbit.r.to(u.AU).value
np.testing.assert_allclose(r_au[0], 1.0, rtol=1e-10)
def test_from_classical_with_units(self):
orbit = Orbit.from_classical(
Earth,
a=7000 << u.km,
ecc=0.01 << u.one,
inc=51.6 << u.deg,
raan=0 << u.deg,
argp=0 << u.deg,
nu=0 << u.deg,
)
assert orbit is not None
a_km = orbit.a.to(u.km).value
np.testing.assert_allclose(a_km, 7000, rtol=1e-6)
inc_deg = orbit.inc.to(u.deg).value
np.testing.assert_allclose(inc_deg, 51.6, rtol=1e-6)
def test_from_classical_with_raw_values(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3, ecc=0.01, inc=np.deg2rad(51.6), raan=0.0,
argp=0.0,
nu=0.0,
)
assert orbit is not None
a_m = orbit.a.to(u.m).value
np.testing.assert_allclose(a_m, 7000e3, rtol=1e-6)
def test_mixed_units_in_from_classical(self):
orbit = Orbit.from_classical(
Earth,
a=1.0 << u.AU, ecc=0.0167 << u.one, inc=7.25 << u.deg, raan=348.7 << u.deg,
argp=114.2 << u.deg,
nu=0 << u.rad, )
assert orbit is not None
a_au = orbit.a.to(u.AU).value
np.testing.assert_allclose(a_au, 1.0, rtol=1e-6)
class TestOrbitPropertiesWithUnits:
@pytest.fixture
def circular_orbit(self):
r = [7000, 0, 0] << u.km
v = [0, 7.546, 0] << u.km / u.s
return Orbit.from_vectors(Earth, r, v)
def test_position_returns_quantity(self, circular_orbit):
r = circular_orbit.r
assert isinstance(r, u.Quantity)
assert r.unit.physical_type == "length"
r_km = r.to(u.km)
assert r_km.value[0] == pytest.approx(7000, rel=1e-6)
def test_velocity_returns_quantity(self, circular_orbit):
v = circular_orbit.v
assert isinstance(v, u.Quantity)
assert v.unit.physical_type == "speed"
v_kms = v.to(u.km / u.s)
assert v_kms.value[1] == pytest.approx(7.546, rel=1e-3)
def test_semi_major_axis_returns_quantity(self, circular_orbit):
a = circular_orbit.a
assert isinstance(a, u.Quantity)
assert a.unit.physical_type == "length"
a_km = a.to(u.km).value
assert a_km == pytest.approx(7000, rel=1e-4)
def test_eccentricity_returns_quantity(self, circular_orbit):
ecc = circular_orbit.ecc
assert isinstance(ecc, u.Quantity)
assert ecc.unit.physical_type == "dimensionless"
assert ecc.value == pytest.approx(0.0, abs=1e-3)
def test_inclination_returns_quantity(self, circular_orbit):
inc = circular_orbit.inc
assert isinstance(inc, u.Quantity)
assert inc.unit.physical_type == "angle"
inc_deg = inc.to(u.deg)
assert isinstance(inc_deg, u.Quantity)
def test_angles_return_quantities(self, circular_orbit):
angles = [circular_orbit.raan, circular_orbit.argp, circular_orbit.nu]
for angle in angles:
assert isinstance(angle, u.Quantity)
assert angle.unit.physical_type == "angle"
angle_deg = angle.to(u.deg)
assert isinstance(angle_deg, u.Quantity)
def test_period_returns_quantity(self, circular_orbit):
period = circular_orbit.period
assert isinstance(period, u.Quantity)
assert period.unit.physical_type == "time"
period_hr = period.to(u.hour)
assert period_hr.value == pytest.approx(1.62, rel=0.01)
def test_mean_motion_returns_quantity(self, circular_orbit):
n = circular_orbit.n
assert isinstance(n, u.Quantity)
n_deg_min = n.to(u.deg / u.min)
assert isinstance(n_deg_min, u.Quantity)
def test_energy_returns_quantity(self, circular_orbit):
energy = circular_orbit.energy
assert isinstance(energy, u.Quantity)
assert energy.value < 0
def test_periapsis_apoapsis_return_quantities(self, circular_orbit):
r_p = circular_orbit.r_p
r_a = circular_orbit.r_a
assert isinstance(r_p, u.Quantity)
assert isinstance(r_a, u.Quantity)
assert r_p.unit.physical_type == "length"
assert r_a.unit.physical_type == "length"
r_p_km = r_p.to(u.km).value
r_a_km = r_a.to(u.km).value
assert r_p_km == pytest.approx(r_a_km, rel=0.01)
class TestUnitConversions:
def test_position_multiple_conversions(self):
r = [1, 0, 0] << u.AU
v = [0, 30, 0] << u.km / u.s
orbit = Orbit.from_vectors(Earth, r, v)
r_m = orbit.r.to(u.m)
r_km = orbit.r.to(u.km)
r_au = orbit.r.to(u.AU)
assert r_au.value[0] == pytest.approx(1.0, rel=1e-6)
assert r_km.value[0] == pytest.approx(1.496e8, rel=1e-3)
def test_angle_degree_radian_conversion(self):
orbit = Orbit.from_classical(
Earth,
a=7000 << u.km,
ecc=0.0 << u.one,
inc=90 << u.deg, raan=0 << u.deg,
argp=0 << u.deg,
nu=0 << u.deg,
)
inc_deg = orbit.inc.to(u.deg).value
inc_rad = orbit.inc.to(u.rad).value
assert inc_deg == pytest.approx(90, rel=1e-6)
assert inc_rad == pytest.approx(np.pi / 2, rel=1e-6)
def test_period_time_unit_conversions(self):
r = [7000, 0, 0] << u.km
v = [0, 7.546, 0] << u.km / u.s
orbit = Orbit.from_vectors(Earth, r, v)
period_s = orbit.period.to(u.s)
period_min = orbit.period.to(u.min)
period_hr = orbit.period.to(u.hour)
assert period_min.value == pytest.approx(period_s.value / 60, rel=1e-6)
assert period_hr.value == pytest.approx(period_s.value / 3600, rel=1e-6)
class TestUnitValidation:
def test_incompatible_position_units_rejected(self):
r = [1, 0, 0] << u.kg v = [0, 7.546, 0] << u.km / u.s
with pytest.raises(ValueError, match="Cannot convert"):
Orbit.from_vectors(Earth, r, v)
def test_incompatible_velocity_units_rejected(self):
r = [7000, 0, 0] << u.km
v = [0, 7.546, 0] << u.km
with pytest.raises(ValueError, match="Cannot convert"):
Orbit.from_vectors(Earth, r, v)
def test_incompatible_angle_units_rejected(self):
with pytest.raises(ValueError, match="Cannot convert"):
Orbit.from_classical(
Earth,
a=7000 << u.km,
ecc=0.0 << u.one,
inc=90 << u.km, raan=0 << u.deg,
argp=0 << u.deg,
nu=0 << u.deg,
)
def test_dimensional_eccentricity_rejected(self):
with pytest.raises(ValueError, match="dimensionless"):
Orbit.from_classical(
Earth,
a=7000 << u.km,
ecc=0.0 << u.km, inc=0 << u.deg,
raan=0 << u.deg,
argp=0 << u.deg,
nu=0 << u.deg,
)
class TestBackwardCompatibility:
def test_raw_arrays_still_work(self):
r = np.array([7000e3, 0, 0])
v = np.array([0, 7546, 0])
orbit = Orbit.from_vectors(Earth, r, v)
assert orbit is not None
assert isinstance(orbit.r, u.Quantity)
def test_raw_floats_still_work(self):
orbit = Orbit.from_classical(
Earth,
a=7000e3,
ecc=0.01,
inc=0.9,
raan=0.0,
argp=0.0,
nu=0.0,
)
assert orbit is not None
def test_mixed_raw_and_quantity(self):
orbit = Orbit.from_classical(
Earth,
a=7000 << u.km, ecc=0.01, inc=51.6 << u.deg, raan=0.0, argp=0.0,
nu=0.0,
)
assert orbit is not None
a_km = orbit.a.to(u.km).value
assert a_km == pytest.approx(7000, rel=1e-6)
if __name__ == "__main__":
pytest.main([__file__, "-v"])