import numpy as np
import pytest
from astropy import units as u
from astrora._core import Duration, Epoch
from astrora.bodies import Earth, Mars
from astrora.maneuver import Maneuver
from astrora.twobody import Orbit
class TestManeuverCreation:
def test_single_impulse(self):
dv = np.array([100.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv))
assert len(maneuver) == 1
t, dv_out = maneuver[0]
assert t == 0.0
np.testing.assert_array_almost_equal(dv_out, dv)
def test_multiple_impulses(self):
dv1 = np.array([100.0, 0.0, 0.0])
dv2 = np.array([0.0, 50.0, 0.0])
dv3 = np.array([0.0, 0.0, 25.0])
maneuver = Maneuver((0.0, dv1), (3600.0, dv2), (7200.0, dv3))
assert len(maneuver) == 3
t0, dv0 = maneuver[0]
assert t0 == 0.0
np.testing.assert_array_almost_equal(dv0, dv1)
t1, dv1_out = maneuver[1]
assert t1 == 3600.0
np.testing.assert_array_almost_equal(dv1_out, dv2)
t2, dv2_out = maneuver[2]
assert t2 == 7200.0
np.testing.assert_array_almost_equal(dv2_out, dv3)
def test_duration_support(self):
dv = np.array([100.0, 0.0, 0.0])
dt = Duration.from_hrs(1)
maneuver = Maneuver((dt, dv))
t, _ = maneuver[0]
assert abs(t - 3600.0) < 1e-6
def test_empty_maneuver_error(self):
with pytest.raises(ValueError, match="at least one impulse"):
Maneuver()
def test_invalid_delta_v_shape(self):
with pytest.raises(ValueError, match="3-element array"):
Maneuver((0.0, np.array([100.0, 0.0])))
def test_impulses_property(self):
dv = np.array([100.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv))
impulses = maneuver.impulses
assert len(impulses) == 1
impulses.append((3600.0, np.array([50.0, 0.0, 0.0])))
assert len(maneuver) == 1
class TestManeuverAnalysis:
def test_total_time_single_impulse(self):
dv = np.array([100.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv))
assert maneuver.get_total_time() == 0.0
def test_total_time_multiple_impulses(self):
dv1 = np.array([100.0, 0.0, 0.0])
dv2 = np.array([50.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv1), (3600.0, dv2))
assert maneuver.get_total_time() == 3600.0
def test_total_time_non_sequential(self):
dv1 = np.array([100.0, 0.0, 0.0])
dv2 = np.array([50.0, 0.0, 0.0])
dv3 = np.array([25.0, 0.0, 0.0])
maneuver = Maneuver((1000.0, dv1), (0.0, dv2), (5000.0, dv3))
assert maneuver.get_total_time() == 5000.0
def test_total_cost_single_impulse(self):
dv = np.array([100.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv))
assert abs(maneuver.get_total_cost() - 100.0) < 1e-6
def test_total_cost_multiple_impulses(self):
dv1 = np.array([100.0, 0.0, 0.0]) dv2 = np.array([30.0, 40.0, 0.0])
maneuver = Maneuver((0.0, dv1), (3600.0, dv2))
expected_cost = 100.0 + 50.0 assert abs(maneuver.get_total_cost() - expected_cost) < 1e-6
def test_total_cost_3d_vectors(self):
dv = np.array([3.0, 4.0, 12.0]) maneuver = Maneuver((0.0, dv))
assert abs(maneuver.get_total_cost() - 13.0) < 1e-6
class TestImpulseFactory:
def test_impulse_basic(self):
dv = np.array([100.0, 50.0, 25.0])
maneuver = Maneuver.impulse(dv)
assert len(maneuver) == 1
t, dv_out = maneuver[0]
assert t == 0.0
np.testing.assert_array_almost_equal(dv_out, dv)
def test_impulse_zero(self):
dv = np.array([0.0, 0.0, 0.0])
maneuver = Maneuver.impulse(dv)
assert maneuver.get_total_cost() == 0.0
class TestHohmannTransfer:
def test_hohmann_leo_to_geo(self):
r_leo = 6778e3 orbit = Orbit.from_classical(Earth, a=r_leo, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
r_geo = 42164e3
maneuver = Maneuver.hohmann(orbit, r_geo)
assert len(maneuver) == 2
t0, dv0 = maneuver[0]
assert t0 == 0.0
t1, dv1 = maneuver[1]
assert t1 > 0
total_dv = maneuver.get_total_cost()
assert total_dv > 0
assert 3800 < total_dv < 4000
def test_hohmann_descending(self):
r_geo = 42164e3
orbit = Orbit.from_classical(Earth, a=r_geo, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
r_leo = 6778e3
maneuver = Maneuver.hohmann(orbit, r_leo)
assert len(maneuver) == 2
total_dv = maneuver.get_total_cost()
assert 3800 < total_dv < 4000
def test_hohmann_eccentric_orbit_error(self):
orbit = Orbit.from_classical(Earth, a=10000e3, ecc=0.5, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
with pytest.raises(ValueError, match="approximately circular"):
Maneuver.hohmann(orbit, 42164e3)
def test_hohmann_transfer_time(self):
r_leo = 6778e3
orbit = Orbit.from_classical(Earth, a=r_leo, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
r_geo = 42164e3
maneuver = Maneuver.hohmann(orbit, r_geo)
transfer_time = maneuver.get_total_time()
expected_time = 5.25 * 3600 assert abs(transfer_time - expected_time) < 600
class TestBiellipticTransfer:
@pytest.mark.xfail(reason="Bielliptic parameter ordering issue in Rust backend")
def test_bielliptic_basic(self):
r_leo = 6778e3
orbit = Orbit.from_classical(Earth, a=r_leo, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
r_final = 20000e3 r_intermediate = 150000e3
maneuver = Maneuver.bielliptic(orbit, r_intermediate, r_final)
assert len(maneuver) == 3
t0, _ = maneuver[0]
t1, _ = maneuver[1]
t2, _ = maneuver[2]
assert t0 == 0.0
assert t1 > t0
assert t2 > t1
total_dv = maneuver.get_total_cost()
assert total_dv > 0
def test_bielliptic_eccentric_orbit_error(self):
orbit = Orbit.from_classical(Earth, a=10000e3, ecc=0.3, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
with pytest.raises(ValueError, match="approximately circular"):
Maneuver.bielliptic(orbit, 100000e3, 50000e3)
class TestLambertTransfer:
@pytest.mark.xfail(reason="Lambert solver convergence issues in Rust backend")
def test_lambert_basic(self):
r1 = np.array([7000e3, 0, 0])
v1 = np.array([0, 7546, 0])
epoch1 = Epoch.j2000_epoch()
orbit1 = Orbit.from_vectors(Earth, r1, v1, epoch1)
dt = Duration.from_hrs(3)
orbit2 = orbit1.propagate(dt)
maneuver = Maneuver.lambert(orbit1, orbit2)
assert len(maneuver) == 2
t0, dv0 = maneuver[0]
assert t0 == 0.0
t1, dv1 = maneuver[1]
assert abs(t1 - dt.to_seconds()) < 1e-6
total_dv = maneuver.get_total_cost()
assert total_dv >= 0
@pytest.mark.xfail(reason="Lambert solver convergence issues in Rust backend")
def test_lambert_different_orbits(self):
epoch1 = Epoch.j2000_epoch()
epoch2 = epoch1 + Duration.from_hrs(3)
orbit1 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch1
)
orbit2 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=np.deg2rad(45), epoch=epoch2
)
maneuver = Maneuver.lambert(orbit1, orbit2)
assert len(maneuver) == 2
assert maneuver.get_total_cost() > 0
def test_lambert_different_attractor_error(self):
epoch1 = Epoch.j2000_epoch()
epoch2 = epoch1 + Duration.from_hrs(6)
orbit1 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch1
)
orbit2 = Orbit.from_classical(
Mars, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch2
)
with pytest.raises(ValueError, match="same attractor"):
Maneuver.lambert(orbit1, orbit2)
def test_lambert_negative_time_error(self):
epoch1 = Epoch.j2000_epoch()
epoch2 = epoch1 + Duration.from_hrs(-6)
orbit1 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch1
)
orbit2 = Orbit.from_classical(
Earth, a=10000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch2
)
with pytest.raises(ValueError, match="after initial"):
Maneuver.lambert(orbit1, orbit2)
@pytest.mark.xfail(reason="Lambert solver convergence issues in Rust backend")
def test_lambert_short_way_vs_long_way(self):
epoch1 = Epoch.j2000_epoch()
epoch2 = epoch1 + Duration.from_hrs(4)
orbit1 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0, epoch=epoch1
)
orbit2 = Orbit.from_classical(
Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=np.deg2rad(60), epoch=epoch2
)
maneuver_short = Maneuver.lambert(orbit1, orbit2, short_way=True)
maneuver_long = Maneuver.lambert(orbit1, orbit2, short_way=False)
assert len(maneuver_short) == 2
assert len(maneuver_long) == 2
assert maneuver_short.get_total_cost() > 0
assert maneuver_long.get_total_cost() > 0
class TestManeuverRepresentation:
def test_repr_single_impulse(self):
dv = np.array([100.0, 0.0, 0.0])
maneuver = Maneuver.impulse(dv)
repr_str = repr(maneuver)
assert "Maneuver with 1 impulse" in repr_str
assert "100.000 m/s" in repr_str
assert "Total Δv: 100.000 m/s" in repr_str
def test_repr_multiple_impulses(self):
dv1 = np.array([100.0, 0.0, 0.0])
dv2 = np.array([50.0, 0.0, 0.0])
maneuver = Maneuver((0.0, dv1), (3600.0, dv2))
repr_str = repr(maneuver)
assert "Maneuver with 2 impulse" in repr_str
assert "t=" in repr_str
assert "Δv=" in repr_str
class TestManeuverIntegration:
def test_apply_impulse_to_orbit(self):
orbit = Orbit.from_classical(Earth, a=7000e3, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0)
v_hat = orbit.v.value / np.linalg.norm(orbit.v.value)
dv = 100 * v_hat
maneuver = Maneuver.impulse(dv)
new_orbit = orbit.apply_maneuver(dv)
assert new_orbit.energy > orbit.energy
def test_hohmann_changes_orbit(self):
r_leo = 7000e3
orbit_leo = Orbit.from_classical(
Earth, a=r_leo, ecc=0.0, inc=0.0, raan=0.0, argp=0.0, nu=0.0
)
r_final = 10000e3
maneuver = Maneuver.hohmann(orbit_leo, r_final)
t0, dv0 = maneuver[0]
orbit_transfer = orbit_leo.apply_maneuver(dv0)
assert orbit_transfer.ecc > 0.01
assert orbit_transfer.a > orbit_leo.a