Skip to content

Commit 3711b30

Browse files
committed
BUG: populate surfaces_cp_to_cdm in flight_rocket()
flight_rocket() copies aerodynamic surfaces directly onto the composed Rocket (bypassing add_surfaces() to avoid double- transforming fin leading-edge positions - see the comment at the copy site). That also skipped evaluate_surfaces_cp_to_cdm(), a step add_surfaces() normally triggers and that Flight.u_dot_generalized requires to apply aerodynamic forces during a real 6DOF simulation. Every existing test flew bare rockets with no surfaces, so this never got exercised - only surfaced when actually running a stage with real aerodynamic surfaces through Mission end-to-end (via the new visualization notebook work). KeyError on rocket.surfaces_cp_to_cdm[aero_surface] the moment such a Flight's ODE integration starts. ENH: add MultiStageRocket.draw() and Mission.all_flights Visualization support, reusing RocketPy's existing plotting entirely - no new drawing or comparison code: - MultiStageRocket.draw() composes every stage via flight_rocket() and hands the result to Rocket's own plots.draw() unchanged. A real multi-diameter stack renders correctly for free, since Rocket's tube-drawing already handles a radius change between surfaces. - Mission.all_flights collects every Flight Mission runs, in execution order, each exactly once (a Flight shared by several bodies, e.g. the full stack, isn't duplicated) - built specifically to feed CompareFlights directly, matching mission_multistage_design.md's stated design ("feeds CompareFlights"). - Every Flight Mission creates now gets a distinguishing name (e.g. "booster+sustainer", "booster", "sustainer") instead of Flight's own "Flight" default, so CompareFlights legends are actually readable with more than one flight in the comparison. Also updates docs/notebooks/multistage_mission.ipynb with draw() and trajectory-comparison demos, executed end-to-end. Departs from this notebook's earlier convention of clearing outputs: plot image outputs are now kept, matching docs/notebooks/getting_started.ipynb rather than the print-only utilities_usage.ipynb, since the whole point of these cells is the rendered image.
1 parent 3c62aa7 commit 3711b30

5 files changed

Lines changed: 591 additions & 92 deletions

File tree

‎docs/notebooks/multistage_mission.ipynb‎

Lines changed: 417 additions & 82 deletions
Large diffs are not rendered by default.

‎rocketpy/rocket/multistage.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ def flight_rocket(self, active_stages, carried_deployables=()):
309309
composed_rocket.evaluate_center_of_pressure()
310310
composed_rocket.evaluate_stability_margin()
311311
composed_rocket.evaluate_static_margin()
312+
# add_motor() already populated surfaces_cp_to_cdm, but from
313+
# before these surfaces were copied in above (it was empty at
314+
# that point) - Flight.u_dot_generalized needs this dict to
315+
# apply aerodynamic forces during a real 6DOF simulation.
316+
composed_rocket.evaluate_surfaces_cp_to_cdm()
312317

313318
return composed_rocket
314319

@@ -377,3 +382,13 @@ def _derive_stack_drag(self, active_stages, attr_name, stack_radius):
377382
)
378383
combined = scaled if combined is None else combined + scaled
379384
return combined
385+
386+
def draw(self, vis_args=None, plane="xz", *, filename=None):
387+
"""Draw the stacked vehicle: every stage's aerodynamic surfaces,
388+
combined exactly as flight_rocket() would compose them for a
389+
flight with every stage attached. Reuses Rocket's own drawing
390+
code unchanged - see :meth:`Rocket.plots.draw` for parameters
391+
and the "at least one aerodynamic surface" requirement.
392+
"""
393+
stack = self.flight_rocket(active_stages=tuple(self.stages))
394+
stack.plots.draw(vis_args, plane, filename=filename)

‎rocketpy/simulation/mission.py‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,18 @@ def __init__(
114114

115115
self.flights = {}
116116
self.timeline = []
117+
self._all_flights = []
117118
self._simulate()
118119

120+
@property
121+
def all_flights(self):
122+
"""Every Flight object, in execution order, each appearing once
123+
(a Flight shared by several bodies, e.g. the full stack, is not
124+
repeated). Feeds CompareFlights directly, e.g.
125+
``CompareFlights(mission.all_flights).trajectories_3d()``.
126+
"""
127+
return list(self._all_flights)
128+
119129
def _simulate(self):
120130
single_stage = len(self.vehicle.stages) == 1
121131
no_deployables = not self.vehicle.deployables
@@ -145,7 +155,7 @@ def _simulate_single_stage(self):
145155
self.timeline.append((0.0, f"ignition:{stage.name}"))
146156
self.timeline.append((0.0, "liftoff"))
147157

148-
flight = self._run_flight(rocket)
158+
flight = self._run_flight(rocket, name=stage.name)
149159

150160
self.timeline.append((flight.out_of_rail_time, "rail_departure"))
151161
self.timeline.append((flight.t_final, f"impact:{stage.name}"))
@@ -188,7 +198,11 @@ def _run_carrier_phase(self, stage, deployable):
188198
self.timeline.append((0.0, f"ignition:{stage.name}"))
189199
self.timeline.append((0.0, "liftoff"))
190200

191-
carrier_flight = self._run_flight(carrier_rocket, terminate_on_apogee=True)
201+
carrier_flight = self._run_flight(
202+
carrier_rocket,
203+
name=f"{stage.name}+{deployable.name}",
204+
terminate_on_apogee=True,
205+
)
192206
self.timeline.append((carrier_flight.out_of_rail_time, "rail_departure"))
193207
self.timeline.append(
194208
(carrier_flight.apogee_time, f"ejection:{deployable.name}")
@@ -207,7 +221,7 @@ def _run_stage_after_ejection_phase(
207221
ending_state, carrier_rocket, stage_rocket, delta_v
208222
)
209223
stage_flight = self._run_flight(
210-
stage_rocket, initial_solution=initial_solution
224+
stage_rocket, name=stage.name, initial_solution=initial_solution
211225
)
212226
self.timeline.append((stage_flight.t_final, f"impact:{stage.name}"))
213227
self.flights[stage.name].append(stage_flight)
@@ -218,7 +232,9 @@ def _run_deployable_phase(self, deployable, carrier_rocket, ending_state, delta_
218232
ending_state, carrier_rocket, deployable.free_rocket, delta_v
219233
)
220234
deployable_flight = self._run_flight(
221-
deployable.free_rocket, initial_solution=initial_solution
235+
deployable.free_rocket,
236+
name=deployable.name,
237+
initial_solution=initial_solution,
222238
)
223239
self.timeline.append(
224240
(deployable_flight.t_final, f"impact:{deployable.name}")
@@ -264,7 +280,11 @@ def _run_stack_phase(self, booster, sustainer):
264280
self.timeline.append((0.0, f"ignition:{booster.name}"))
265281
self.timeline.append((0.0, "liftoff"))
266282

267-
stack_flight = self._run_flight(stack_rocket, max_time=separation_time)
283+
stack_flight = self._run_flight(
284+
stack_rocket,
285+
name=f"{booster.name}+{sustainer.name}",
286+
max_time=separation_time,
287+
)
268288
self.timeline.append((stack_flight.out_of_rail_time, "rail_departure"))
269289
self.timeline.append((separation_time, f"separation:{booster.name}"))
270290

@@ -279,7 +299,7 @@ def _run_booster_phase(self, booster, stack_rocket, ending_state, delta_v):
279299
ending_state, stack_rocket, booster_rocket, delta_v
280300
)
281301
booster_flight = self._run_flight(
282-
booster_rocket, initial_solution=initial_solution
302+
booster_rocket, name=booster.name, initial_solution=initial_solution
283303
)
284304
self.timeline.append((booster_flight.t_final, f"impact:{booster.name}"))
285305
self.flights[booster.name].append(booster_flight)
@@ -298,16 +318,28 @@ def _run_sustainer_phase(
298318
ending_state, stack_rocket, sustainer_rocket, delta_v
299319
)
300320
sustainer_flight = self._run_flight(
301-
sustainer_rocket, initial_solution=initial_solution
321+
sustainer_rocket, name=sustainer.name, initial_solution=initial_solution
302322
)
303323
self.timeline.append((sustainer_flight.t_final, f"impact:{sustainer.name}"))
304324
self.flights[sustainer.name].append(sustainer_flight)
305325

306326
def _run_flight(
307-
self, rocket, initial_solution=None, max_time=None, terminate_on_apogee=False
327+
self,
328+
rocket,
329+
name,
330+
initial_solution=None,
331+
max_time=None,
332+
terminate_on_apogee=False,
308333
):
309-
"""Run one Flight in absolute mission time."""
310-
return Flight(
334+
"""Run one Flight in absolute mission time.
335+
336+
The single choke point through which every Flight Mission creates
337+
is constructed, so this is also where all_flights collects them,
338+
in execution order, each exactly once. ``name`` distinguishes
339+
each Flight in plots such as CompareFlights, which otherwise
340+
labels every line "Flight" (Flight's own default).
341+
"""
342+
flight = Flight(
311343
rocket=rocket,
312344
environment=self.environment,
313345
rail_length=self.rail_length,
@@ -321,7 +353,10 @@ def _run_flight(
321353
time_overshoot=self.time_overshoot,
322354
ode_solver=self.ode_solver,
323355
verbose=self.verbose,
356+
name=name,
324357
)
358+
self._all_flights.append(flight)
359+
return flight
325360

326361
@staticmethod
327362
def _momentum_split(mass_a, mass_b, delta_v):

‎tests/unit/rocket/test_multistage.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,42 @@ def test_flight_rocket_combines_surfaces_of_every_active_stage(calisto_nose_cone
218218

219219
assert len(two_stage_rocket.aerodynamic_surfaces) == 1
220220
assert len(sustainer_alone_rocket.aerodynamic_surfaces) == 1
221+
222+
223+
def test_flight_rocket_populates_surfaces_cp_to_cdm(calisto, calisto_nose_cone):
224+
# surfaces_cp_to_cdm is separate from center_of_pressure/stability -
225+
# it's what Flight.u_dot_generalized looks up per surface to apply
226+
# aerodynamic forces during a real 6DOF simulation. flight_rocket()
227+
# copies surfaces directly (bypassing add_surfaces() to avoid
228+
# double-transforming fin leading-edge positions), which must not
229+
# skip populating this dict too.
230+
stage = Stage(name="stage_1", rocket=calisto)
231+
stage.rocket.add_surfaces(calisto_nose_cone, 1.0)
232+
vehicle = MultiStageRocket(stages=[stage])
233+
234+
composed_rocket = vehicle.flight_rocket(active_stages=(stage,))
235+
236+
assert calisto_nose_cone in composed_rocket.surfaces_cp_to_cdm
237+
238+
239+
def test_draw_runs_for_a_stage_with_aerodynamic_surfaces(calisto_robust):
240+
stage = Stage(name="stage_1", rocket=calisto_robust)
241+
vehicle = MultiStageRocket(stages=[stage])
242+
243+
assert vehicle.draw(filename=None) is None
244+
245+
246+
def test_draw_combines_surfaces_of_every_stage(calisto_nose_cone):
247+
booster, sustainer = _two_stage_vehicle()
248+
booster.rocket.add_surfaces(calisto_nose_cone, 0.5)
249+
vehicle = MultiStageRocket(stages=[booster, sustainer])
250+
251+
assert vehicle.draw(filename=None) is None
252+
253+
254+
def test_draw_raises_when_no_stage_has_aerodynamic_surfaces():
255+
booster, sustainer = _two_stage_vehicle()
256+
vehicle = MultiStageRocket(stages=[booster, sustainer])
257+
258+
with pytest.raises(ValueError):
259+
vehicle.draw(filename=None)

‎tests/unit/simulation/test_mission.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from rocketpy import Flight
44
from rocketpy.mathutils.vector_matrix import Matrix, Vector
55
from rocketpy.motors.point_mass_motor import PointMassMotor
6+
from rocketpy.plots.compare.compare_flights import CompareFlights
67
from rocketpy.rocket.multistage import MultiStageRocket, Stage
78
from rocketpy.rocket.rocket import Rocket
89
from rocketpy.simulation.mission import Mission
@@ -377,3 +378,77 @@ def test_deployable_splits_separation_delta_v_by_momentum_conservation(
377378
assert payload_flight.solution[0][6] == pytest.approx(
378379
ending_vz + expected_payload_delta_v
379380
)
381+
382+
383+
def test_all_flights_is_the_single_flight_for_the_degenerate_case(
384+
calisto, example_plain_env
385+
):
386+
mission = Mission(
387+
vehicle=calisto,
388+
environment=example_plain_env,
389+
rail_length=5.2,
390+
inclination=85,
391+
heading=0,
392+
)
393+
394+
assert mission.all_flights == [mission.flights["stage_1"][0]]
395+
396+
397+
def test_all_flights_has_no_duplicate_for_the_shared_stack_flight(example_plain_env):
398+
booster, sustainer = _two_stage_vehicle(booster_separation=0.5)
399+
vehicle = MultiStageRocket(stages=[booster, sustainer])
400+
401+
mission = Mission(
402+
vehicle=vehicle,
403+
environment=example_plain_env,
404+
rail_length=1.0,
405+
inclination=90,
406+
heading=0,
407+
max_time=5,
408+
)
409+
410+
assert len(mission.all_flights) == 3
411+
assert len(set(id(flight) for flight in mission.all_flights)) == 3
412+
assert mission.all_flights == [
413+
mission.flights["booster"][0],
414+
mission.flights["booster"][1],
415+
mission.flights["sustainer"][1],
416+
]
417+
418+
419+
def test_all_flights_feeds_compare_flights(example_plain_env):
420+
booster, sustainer = _two_stage_vehicle(booster_separation=0.5)
421+
vehicle = MultiStageRocket(stages=[booster, sustainer])
422+
423+
mission = Mission(
424+
vehicle=vehicle,
425+
environment=example_plain_env,
426+
rail_length=1.0,
427+
inclination=90,
428+
heading=0,
429+
max_time=5,
430+
)
431+
432+
assert CompareFlights(mission.all_flights).trajectories_3d(filename=None) is None
433+
434+
435+
def test_flight_names_distinguish_each_body_and_phase(example_plain_env):
436+
# CompareFlights labels each line in a plot legend using flight.name;
437+
# every Flight Mission creates must have a name that distinguishes it
438+
# (Flight's own default "Flight" would make every legend entry
439+
# identical and useless).
440+
booster, sustainer = _two_stage_vehicle(booster_separation=0.5)
441+
vehicle = MultiStageRocket(stages=[booster, sustainer])
442+
443+
mission = Mission(
444+
vehicle=vehicle,
445+
environment=example_plain_env,
446+
rail_length=1.0,
447+
inclination=90,
448+
heading=0,
449+
max_time=5,
450+
)
451+
452+
names = [flight.name for flight in mission.all_flights]
453+
assert len(names) == len(set(names))
454+
assert all(name != "Flight" for name in names)

0 commit comments

Comments
 (0)