diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f24f5c90..1c9586955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: rocket with a late-starting thrust curve never leaves the rail [#1085](https://github.com/RocketPy-Team/RocketPy/pull/1085) + ## [v1.13.0] - 2026-07-21 ### Added diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index e026d166e..55ca3486f 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -848,6 +848,18 @@ def __setup_phase_time_nodes(self, phase): # Add last time node phase.time_nodes.add_node(phase.time_bound, [], [], []) + # A thrust curve that starts at t > 0 leaves the rocket stationary at + # ignition-minus, so the solver (max_step defaults to inf) can take one + # huge step clean over the burn and the rocket never lifts off (#411). + # Force solver stops at ignition and burn-out so the burn is always + # sampled. Guarded to burn_start > 0, so ordinary motors are untouched. + motor = self.rocket.motor + burn_start = getattr(motor, "burn_start_time", 0) or 0 + if burn_start > 0: + for t_burn in (motor.burn_start_time, motor.burn_out_time): + if phase.t < t_burn < phase.time_bound: + phase.time_nodes.add_node(t_burn, [], [], []) + # Organize time nodes phase.time_nodes.sort() phase.time_nodes.merge() diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index 7983c0348..2eaa1609b 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -5,11 +5,72 @@ import numpy.testing as npt import pytest -from rocketpy import Flight +from rocketpy import Flight, Rocket, SolidMotor plt.rcParams.update({"figure.max_open_warning": 0}) +def test_flight_with_delayed_burn_leaves_rail(example_plain_env): + """Regression test for #411: a motor whose thrust curve starts at t > 0 must + still lift the rocket off the rail. Previously the solver stepped over the + burn (``max_step`` defaults to ``inf``) so the rocket never left the pad + (``out_of_rail_time`` came out as 0). + + Parameters + ---------- + example_plain_env : rocketpy.Environment + A plain environment object, this is a pytest fixture. + """ + motor = SolidMotor( + thrust_source=[(8.0, 1500.0), (9.0, 2000.0), (14.0, 2000.0), (20.0, 0.0)], + burn_time=(8, 20), + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + nozzle_radius=33 / 1000, + grain_number=5, + grain_density=1815, + grain_outer_radius=33 / 1000, + grain_initial_inner_radius=15 / 1000, + grain_initial_height=120 / 1000, + grain_separation=5 / 1000, + grains_center_of_mass_position=0.397, + center_of_dry_mass_position=0.317, + nozzle_position=0, + throat_radius=11 / 1000, + coordinate_system_orientation="nozzle_to_combustion_chamber", + ) + rocket = Rocket( + radius=127 / 2000, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=0.5, + power_on_drag=0.5, + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", + ) + rocket.add_motor(motor, position=-1.255) + rocket.add_nose(length=0.55829, kind="vonKarman", position=1.278) + rocket.add_trapezoidal_fins( + n=4, root_chord=0.120, tip_chord=0.060, span=0.110, position=-1.04956 + ) + rocket.set_rail_buttons(0.082, -0.618) + + flight = Flight( + rocket=rocket, + environment=example_plain_env, + rail_length=5.2, + inclination=85, + heading=0, + max_time=60, + ) + + # Ignition is at t = 8 s; the rocket must leave the rail shortly after, + # not remain at the pad the whole flight. + assert flight.out_of_rail_time > 8.0 + assert flight.out_of_rail_velocity > 10.0 + assert flight.apogee - example_plain_env.elevation > 1000.0 + + @pytest.mark.parametrize( "flight_fixture", ["flight_calisto_robust", "flight_calisto_robust_solid_eom"] )