Skip to content

Commit f3bfe42

Browse files
committed
ENH: add Deployable to rocketpy.rocket.multistage
Second slice of the multistage/mission architecture. Deployable is the mission-layer wrapper for an inert carried body (payload, nose cone, etc.) that gets ejected during flight: mass/inertia/position while attached, plus either a fully built free_rocket or surfaces added via add_surface() for its free-flight aerodynamics - mutually exclusive, matching mission_multistage_design.md. Also revises SeparableBody from the previous commit: it no longer holds a shared "separation" attribute. Stage's release event is named separation, Deployable's is named ejection - forcing both through one base attribute name was misleading, so each subclass now owns its own.
1 parent aff0a2b commit f3bfe42

2 files changed

Lines changed: 151 additions & 8 deletions

File tree

‎rocketpy/rocket/multistage.py‎

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@ class SeparableBody:
1010
name : str
1111
Unique body name within the vehicle; used to group Mission
1212
results (e.g. ``mission.flights["booster"]``).
13-
separation : Event, optional
14-
Event that releases this body. If None, never separates.
1513
separation_delta_v : float, optional
1614
Relative separation speed along the stack's longitudinal axis,
1715
in m/s, split by momentum conservation. Default 0.
16+
17+
Notes
18+
-----
19+
Subclasses each hold their own release-event attribute under its own
20+
name (``Stage.separation``, ``Deployable.ejection``) rather than a
21+
shared base attribute, since the two events are conceptually similar
22+
but not interchangeable.
1823
"""
1924

20-
def __init__(self, name, separation=None, separation_delta_v=0.0):
25+
def __init__(self, name, separation_delta_v=0.0):
2126
self.name = name
22-
self.separation = separation
2327
self.separation_delta_v = separation_delta_v
2428

2529

@@ -56,10 +60,9 @@ def __init__(
5660
ignition=None,
5761
ignition_delay=0.0,
5862
):
59-
super().__init__(
60-
name=name, separation=separation, separation_delta_v=separation_delta_v
61-
)
63+
super().__init__(name=name, separation_delta_v=separation_delta_v)
6264
self.rocket = rocket
65+
self.separation = separation
6366
self.ignition = ignition
6467
self.ignition_delay = ignition_delay
6568

@@ -72,3 +75,82 @@ def burn_out_time(self):
7275
def dry_mass(self):
7376
"""Stage dry mass (structure + motor dry mass), in kg."""
7477
return self.rocket.dry_mass
78+
79+
80+
class Deployable(SeparableBody):
81+
"""An inert body carried by the vehicle and ejected in flight.
82+
83+
No aerodynamic identity while attached: contributes only mass and
84+
inertia at a position inside the carrying stage.
85+
86+
Free-flight aerodynamics come from one of two sources, mutually
87+
exclusive:
88+
- ``free_rocket``: a fully built Rocket or PointMassRocket (full
89+
control);
90+
- surfaces added with ``add_surface()``: Mission assembles the
91+
free-flight Rocket from the deployable's mass, inertia, radius and
92+
the added surfaces.
93+
94+
Parameters
95+
----------
96+
name : str
97+
Unique body name; used to group Mission results.
98+
mass : float
99+
Carried mass in kg.
100+
inertia : tuple of float
101+
Inertia (I11, I22, I33) about the deployable's own center of
102+
mass, in kg*m^2.
103+
position : float
104+
Position of the deployable's center of mass in the carrying
105+
stage's rocket coordinate system, in meters.
106+
radius : float, optional
107+
The deployable's largest radius in meters. Required only when
108+
defining its free-flight aerodynamics via add_surface().
109+
free_rocket : Rocket, PointMassRocket, optional
110+
Free-flight configuration after ejection. Mutually exclusive
111+
with add_surface().
112+
ejection : Event, optional
113+
When the deployable is released, e.g. Event(trigger="apogee").
114+
separation_delta_v : float, optional
115+
See SeparableBody.
116+
"""
117+
118+
def __init__(
119+
self,
120+
name,
121+
mass,
122+
inertia,
123+
position,
124+
radius=None,
125+
free_rocket=None,
126+
ejection=None,
127+
separation_delta_v=0.0,
128+
):
129+
super().__init__(name=name, separation_delta_v=separation_delta_v)
130+
self.mass = mass
131+
self.inertia = inertia
132+
self.position = position
133+
self.radius = radius
134+
self.free_rocket = free_rocket
135+
self.ejection = ejection
136+
self.surfaces = []
137+
138+
def add_surface(self, surface, position):
139+
"""Add an aerodynamic surface to the deployable's free flight.
140+
141+
Takes effect only after ejection; while attached the deployable
142+
still contributes only mass and inertia. ``position`` is in the
143+
deployable's own coordinate system, in meters. Requires
144+
``radius`` to be set and is mutually exclusive with
145+
``free_rocket``.
146+
"""
147+
if self.free_rocket is not None:
148+
raise ValueError(
149+
"add_surface is mutually exclusive with free_rocket; "
150+
"a free_rocket was already provided for this deployable."
151+
)
152+
if self.radius is None:
153+
raise ValueError(
154+
"add_surface requires radius to be set on the deployable."
155+
)
156+
self.surfaces.append((surface, position))

‎tests/unit/rocket/test_multistage.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from rocketpy.rocket.multistage import Stage
1+
import pytest
2+
3+
from rocketpy.rocket.multistage import Deployable, Stage
24

35

46
def test_stage_dry_mass_matches_wrapped_rocket(calisto):
@@ -11,3 +13,62 @@ def test_stage_burn_out_time_matches_wrapped_rocket_motor(calisto):
1113
stage = Stage(name="stage_1", rocket=calisto)
1214

1315
assert stage.burn_out_time == calisto.motor.burn_out_time
16+
17+
18+
def test_deployable_stores_constructor_arguments():
19+
deployable = Deployable(
20+
name="payload",
21+
mass=4.5,
22+
inertia=(0.1, 0.1, 0.001),
23+
position=1.10,
24+
radius=0.05,
25+
)
26+
27+
assert deployable.name == "payload"
28+
assert deployable.mass == 4.5
29+
assert deployable.inertia == (0.1, 0.1, 0.001)
30+
assert deployable.position == 1.10
31+
assert deployable.radius == 0.05
32+
assert deployable.free_rocket is None
33+
assert deployable.ejection is None
34+
assert not deployable.surfaces
35+
36+
37+
def test_add_surface_raises_when_free_rocket_already_set(calisto_nose_cone):
38+
deployable = Deployable(
39+
name="payload",
40+
mass=4.5,
41+
inertia=(0.1, 0.1, 0.001),
42+
position=1.10,
43+
radius=0.05,
44+
free_rocket=object(),
45+
)
46+
47+
with pytest.raises(ValueError):
48+
deployable.add_surface(calisto_nose_cone, position=0.5)
49+
50+
51+
def test_add_surface_raises_when_radius_not_set(calisto_nose_cone):
52+
deployable = Deployable(
53+
name="payload",
54+
mass=4.5,
55+
inertia=(0.1, 0.1, 0.001),
56+
position=1.10,
57+
)
58+
59+
with pytest.raises(ValueError):
60+
deployable.add_surface(calisto_nose_cone, position=0.5)
61+
62+
63+
def test_add_surface_appends_surface_and_position(calisto_nose_cone):
64+
deployable = Deployable(
65+
name="payload",
66+
mass=4.5,
67+
inertia=(0.1, 0.1, 0.001),
68+
position=1.10,
69+
radius=0.05,
70+
)
71+
72+
deployable.add_surface(calisto_nose_cone, position=0.5)
73+
74+
assert deployable.surfaces == [(calisto_nose_cone, 0.5)]

0 commit comments

Comments
 (0)