Skip to content

Commit 442ba76

Browse files
authored
ClusterMotor initialization and add docstring
Addresses pull request feedback on the new ClusterMotor class. The monolithic __init__ method was large and difficult to maintain. This commit refactors the constructor by breaking its logic into several smaller, private helper methods: - `_validate_inputs`: Handles all input validation and normalization. - `_initialize_basic_properties`: Calculates scalar values (mass, impulse, burn time). - `_initialize_thrust_and_mass`: Sets up thrust, mass flow rate, and propellant mass Functions. - `_initialize_center_of_mass`: Sets up CoM and CoPM Functions. - `_initialize_inertia_properties`: Calculates dry inertia and sets up propellant inertia Functions. This improves readability and separates concerns. Additionally: - Adds a NumPy-style docstring to the `__init__` method. - Cleans up inline comments, retaining all essential docstrings.
1 parent c2440f5 commit 442ba76

File tree

1 file changed

+121
-56
lines changed

1 file changed

+121
-56
lines changed

rocketpy/motors/ClusterMotor

Lines changed: 121 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,45 @@ from rocketpy.tools import (
1616
class ClusterMotor:
1717
"""
1818
Manages a cluster of motors.
19+
1920
This class behaves like a single motor by aggregating the properties
2021
of several individual motors and implementing the full interface
21-
expected by the Rocket class.
22+
expected by the Rocket class. It handles the calculation of
23+
aggregated thrust, mass, center of mass, and inertia tensor as
24+
functions of time, considering the 3D position and orientation
25+
of each motor.
2226
"""
2327

2428
def __init__(self, motors, positions, orientations=None):
29+
"""Initializes the ClusterMotor, aggregating multiple motors.
30+
31+
Parameters
32+
----------
33+
motors : list[Motor]
34+
A list of instantiated RocketPy Motor objects to be part of the cluster.
35+
positions : list[tuple] or list[list] or list[np.array]
36+
A list of 3D position vectors [x, y, z] for each motor.
37+
The position is relative to the rocket's coordinate system origin,
38+
which is also the cluster's origin. The coordinate system
39+
orientation is assumed to be 'tail_to_nose'.
40+
orientations : list[tuple] or list[list] or list[np.array], optional
41+
A list of 3D unit vectors [x, y, z] specifying the thrust
42+
direction for each motor in the rocket's coordinate system.
43+
If None, all motors are assumed to thrust along the rocket's
44+
positive Z-axis (e.g., [0, 0, 1]). Defaults to None.
45+
"""
46+
self._validate_inputs(motors, positions, orientations)
47+
48+
self.coordinate_system_orientation = "tail_to_nose"
49+
self._csys = 1
50+
51+
self._initialize_basic_properties()
52+
self._initialize_thrust_and_mass()
53+
self._initialize_center_of_mass()
54+
self._initialize_inertia_properties()
55+
56+
def _validate_inputs(self, motors, positions, orientations):
57+
"""Validates and stores the primary inputs for the cluster."""
2558
if not motors:
2659
raise ValueError("The list of motors cannot be empty.")
2760

@@ -40,52 +73,51 @@ class ClusterMotor:
4073
"The 'motors', 'positions', and 'orientations' lists must have the same length."
4174
)
4275

43-
self._csys = 1
44-
self.coordinate_system_orientation = "tail_to_nose"
45-
46-
# 1. Basic properties
76+
def _initialize_basic_properties(self):
77+
"""Calculates simple aggregated scalar properties."""
4778
self.propellant_initial_mass = sum(
4879
m.propellant_initial_mass for m in self.motors
4980
)
5081
self.dry_mass = sum(m.dry_mass for m in self.motors)
82+
self.total_impulse = sum(m.total_impulse for m in self.motors)
5183

52-
# 2. Burn time
5384
self.burn_start_time = min(motor.burn_start_time for motor in self.motors)
5485
self.burn_out_time = max(motor.burn_out_time for motor in self.motors)
5586
self.burn_time = (self.burn_start_time, self.burn_out_time)
5687

57-
# 3. Thrust and Impulse
88+
self.nozzle_radius = np.sqrt(sum(m.nozzle_radius**2 for m in self.motors))
89+
self.throat_radius = np.sqrt(sum(m.throat_radius**2 for m in self.motors))
90+
91+
self.nozzle_position = np.mean([
92+
pos[2] + motor.nozzle_position * motor._csys
93+
for motor, pos in zip(self.motors, self.positions)
94+
])
95+
96+
def _initialize_thrust_and_mass(self):
97+
"""Initializes thrust and mass-related Function objects."""
5898
self.thrust = Function(
5999
self._calculate_total_thrust_scalar, inputs="t", outputs="Thrust (N)"
60100
)
61101
if self.burn_time[1] > self.burn_time[0]:
62102
self.thrust.set_discrete(self.burn_start_time, self.burn_out_time, 100)
63103

64-
self.total_impulse = sum(m.total_impulse for m in self.motors)
65-
66-
# 4. Mass flow rate (Independent calculation, based on impulse)
67-
if self.propellant_initial_mass > 0:
104+
if self.propellant_initial_mass > 1e-9:
68105
average_exhaust_velocity = self.total_impulse / self.propellant_initial_mass
69106
self.total_mass_flow_rate = self.thrust / -average_exhaust_velocity
70107
else:
71108
self.total_mass_flow_rate = Function(0)
109+
110+
self.mass_flow_rate = self.total_mass_flow_rate
72111

73-
self.mass_flow_rate = self.total_mass_flow_rate # Alias
74-
75-
# 5. Propellant mass (based on flow rate)
76112
self.propellant_mass = Function(
77113
self._calculate_propellant_mass, inputs="t", outputs="Propellant Mass (kg)"
78114
)
79-
80-
# 6. Total mass (based on propellant mass)
81115
self.total_mass = Function(
82116
self._calculate_total_mass, inputs="t", outputs="Total Mass (kg)"
83117
)
84118

85-
self.nozzle_radius = np.sqrt(sum(m.nozzle_radius**2 for m in self.motors))
86-
self.throat_radius = np.sqrt(sum(m.throat_radius**2 for m in self.motors))
87-
self.nozzle_position = np.mean([pos[2] for pos in self.positions])
88-
119+
def _initialize_center_of_mass(self):
120+
"""Initializes center of mass Function objects."""
89121
self.center_of_mass = Function(
90122
self._calculate_center_of_mass, inputs="t", outputs="Cluster CoM Vector (m)"
91123
)
@@ -94,11 +126,14 @@ class ClusterMotor:
94126
inputs="t",
95127
outputs="Cluster CoPM Vector (m)",
96128
)
97-
129+
130+
com_time = self.burn_out_time if self.burn_out_time > self.burn_start_time else self.burn_start_time
98131
self.center_of_dry_mass_position = self._calculate_center_of_mass(
99-
self.burn_out_time
132+
com_time
100133
)
101134

135+
def _initialize_inertia_properties(self):
136+
"""Initializes dry and propellant inertia properties."""
102137
(
103138
self.dry_I_11,
104139
self.dry_I_22,
@@ -112,48 +147,61 @@ class ClusterMotor:
112147
return self._calculate_total_propellant_inertia(t)
113148

114149
self.propellant_I_11 = Function(
115-
lambda t: propellant_inertia_func(t)[0], inputs="t"
150+
lambda t: propellant_inertia_func(t)[0], inputs="t", outputs="I_11 (kg*m^2)"
116151
)
117152
self.propellant_I_22 = Function(
118-
lambda t: propellant_inertia_func(t)[1], inputs="t"
153+
lambda t: propellant_inertia_func(t)[1], inputs="t", outputs="I_22 (kg*m^2)"
119154
)
120155
self.propellant_I_33 = Function(
121-
lambda t: propellant_inertia_func(t)[2], inputs="t"
156+
lambda t: propellant_inertia_func(t)[2], inputs="t", outputs="I_33 (kg*m^2)"
122157
)
123158
self.propellant_I_12 = Function(
124-
lambda t: propellant_inertia_func(t)[3], inputs="t"
159+
lambda t: propellant_inertia_func(t)[3], inputs="t", outputs="I_12 (kg*m^2)"
125160
)
126161
self.propellant_I_13 = Function(
127-
lambda t: propellant_inertia_func(t)[4], inputs="t"
162+
lambda t: propellant_inertia_func(t)[4], inputs="t", outputs="I_13 (kg*m^2)"
128163
)
129164
self.propellant_I_23 = Function(
130-
lambda t: propellant_inertia_func(t)[5], inputs="t"
165+
lambda t: propellant_inertia_func(t)[5], inputs="t", outputs="I_23 (kg*m^2)"
131166
)
132-
167+
133168
def _calculate_total_mass(self, t):
169+
"""Calculates total cluster mass at time t."""
134170
return self.dry_mass + self._calculate_propellant_mass(t)
135171

136172
def _calculate_propellant_mass(self, t):
137-
"""Calculates the total propellant mass at time t by integrating the total mass flow rate."""
138-
# Note: mass flow rate is negative (mass leaving), so the integral is negative.
173+
"""
174+
Calculates the total propellant mass at time t by integrating the
175+
total mass flow rate. This ensures consistency between mass and thrust.
176+
"""
139177
return self.propellant_initial_mass + self.total_mass_flow_rate.integral(
140178
self.burn_start_time, t
141179
)
142180

143-
def _calculate_total_mass_flow_rate(self, t):
144-
return -self.propellant_mass.differentiate(t, dx=1e-6)
145-
146181
def get_total_thrust_vector(self, t):
182+
"""
183+
Calculates the total thrust vector of the cluster at a given time t.
184+
This vector is the sum of all individual motor thrust vectors,
185+
considering their orientation.
186+
"""
147187
total_thrust = np.zeros(3)
148188
for motor, orientation in zip(self.motors, self.orientations):
149189
scalar_thrust = motor.thrust.get_value_opt(t)
150190
total_thrust += scalar_thrust * orientation
151191
return Vector(total_thrust)
152192

153193
def _calculate_total_thrust_scalar(self, t):
194+
"""
195+
Calculates the magnitude of the total thrust vector.
196+
This is what is wrapped by the `self.thrust` Function.
197+
"""
154198
return abs(self.get_total_thrust_vector(t))
155199

156200
def get_total_moment(self, t, ref_point):
201+
"""
202+
Calculates the total moment (torque) generated by the cluster
203+
about a given reference point (e.g., the rocket's CoM).
204+
"""
157205
total_moment = np.zeros(3, dtype=np.float64)
158206
ref_point_arr = np.array(ref_point, dtype=np.float64)
159207

@@ -174,72 +222,90 @@ class ClusterMotor:
174222
return Vector(total_moment)
175223

176224
def pressure_thrust(self, pressure):
225+
"""Calculates the total pressure thrust correction for the cluster."""
177226
return sum(motor.pressure_thrust(pressure) for motor in self.motors)
178227

179228
def _calculate_center_of_mass(self, t):
229+
"""Calculates the aggregated center of mass of the cluster at time t."""
180230
total_mass_val = self._calculate_total_mass(t)
181-
if total_mass_val == 0:
231+
if total_mass_val < 1e-9:
182232
return Vector(
183233
np.mean(self.positions, axis=0) if self.positions else np.zeros(3)
184234
)
185235

186236
weighted_sum = np.zeros(3, dtype=np.float64)
187237
for motor, pos in zip(self.motors, self.positions):
188-
motor_com_local = motor.center_of_mass.get_value_opt(t)
189-
motor_com_global = pos + np.array([0, 0, motor_com_local * motor._csys])
238+
motor_com_local_z = motor.center_of_mass.get_value_opt(t)
239+
motor_com_global = pos + np.array([0, 0, motor_com_local_z * motor._csys])
190240
weighted_sum += motor.total_mass.get_value_opt(t) * motor_com_global
191241

192242
return Vector(weighted_sum / total_mass_val)
193243

194244
def _calculate_center_of_propellant_mass(self, t):
195-
total_prop_mass = self._calculate_propellant_mass(t)
196-
if total_prop_mass == 0:
245+
"""
246+
Calculates the aggregated center of mass of the cluster's propellant.
247+
This calculation is based on the individual motor properties.
248+
"""
249+
total_prop_mass = 0.0
250+
weighted_sum = np.zeros(3, dtype=np.float64)
251+
252+
for motor in self.motors:
253+
total_prop_mass += motor.propellant_mass.get_value_opt(t)
254+
255+
if total_prop_mass < 1e-9:
197256
return self.center_of_dry_mass_position
198257

199-
weighted_sum = np.zeros(3, dtype=np.float64)
200258
for motor, pos in zip(self.motors, self.positions):
201-
prop_com_local = motor.center_of_propellant_mass.get_value_opt(t)
202-
prop_com_global = pos + np.array([0, 0, prop_com_local * motor._csys])
203-
weighted_sum += motor.propellant_mass.get_value_opt(t) * prop_com_global
259+
prop_mass_t = motor.propellant_mass.get_value_opt(t)
260+
if prop_mass_t > 1e-9:
261+
prop_com_local_z = motor.center_of_propellant_mass.get_value_opt(t)
262+
prop_com_global = pos + np.array([0, 0, prop_com_local_z * motor._csys])
263+
weighted_sum += prop_mass_t * prop_com_global
204264

205265
return Vector(weighted_sum / total_prop_mass)
206266

207267
def _calculate_total_dry_inertia(self):
208-
I_11, I_22, I_33 = 0, 0, 0
209-
I_12, I_13, I_23 = 0, 0, 0
268+
"""
269+
Calculates the cluster's total dry inertia tensor relative to the
270+
cluster's center of dry mass.
271+
"""
272+
I_11, I_22, I_33 = 0.0, 0.0, 0.0
273+
I_12, I_13, I_23 = 0.0, 0.0, 0.0
210274

211275
ref_point = self.center_of_dry_mass_position
212276

213277
for motor, pos in zip(self.motors, self.positions):
214278
m = motor.dry_mass
215-
motor_cdm_global = pos + np.array(
216-
[0, 0, motor.center_of_dry_mass_position * motor._csys]
217-
)
279+
motor_cdm_local_z = motor.center_of_dry_mass_position
280+
motor_cdm_global = pos + np.array([0, 0, motor_cdm_local_z * motor._csys])
218281
r_vec = Vector(motor_cdm_global) - ref_point
219282

220283
I_11 += parallel_axis_theorem_I11(motor.dry_I_11, m, r_vec)
221284
I_22 += parallel_axis_theorem_I22(motor.dry_I_22, m, r_vec)
222285
I_33 += parallel_axis_theorem_I33(motor.dry_I_33, m, r_vec)
223-
224286
I_12 += parallel_axis_theorem_I12(motor.dry_I_12, m, r_vec)
225287
I_13 += parallel_axis_theorem_I13(motor.dry_I_13, m, r_vec)
226288
I_23 += parallel_axis_theorem_I23(motor.dry_I_23, m, r_vec)
227289

228290
return I_11, I_22, I_33, I_12, I_13, I_23
229291

230292
def _calculate_total_propellant_inertia(self, t):
231-
I_11, I_22, I_33 = 0, 0, 0
232-
I_12, I_13, I_23 = 0, 0, 0
293+
"""
294+
Calculates the cluster's total propellant inertia tensor relative to the
295+
cluster's center of propellant mass at time t.
296+
"""
297+
I_11, I_22, I_33 = 0.0, 0.0, 0.0
298+
I_12, I_13, I_23 = 0.0, 0.0, 0.0
233299

234300
ref_point = self._calculate_center_of_propellant_mass(t)
235301

236302
for motor, pos in zip(self.motors, self.positions):
237303
m = motor.propellant_mass.get_value_opt(t)
238-
if m == 0:
304+
if m < 1e-9:
239305
continue
240306

241-
prop_com_local = motor.center_of_propellant_mass.get_value_opt(t)
242-
prop_com_global = pos + np.array([0, 0, prop_com_local * motor._csys])
307+
prop_com_local_z = motor.center_of_propellant_mass.get_value_opt(t)
308+
prop_com_global = pos + np.array([0, 0, prop_com_local_z * motor._csys])
243309
r_vec = Vector(prop_com_global) - ref_point
244310

245311
I_11 += parallel_axis_theorem_I11(
@@ -251,7 +317,6 @@ class ClusterMotor:
251317
I_33 += parallel_axis_theorem_I33(
252318
motor.propellant_I_33.get_value_opt(t), m, r_vec
253319
)
254-
255320
I_12 += parallel_axis_theorem_I12(
256321
motor.propellant_I_12.get_value_opt(t), m, r_vec
257322
)
@@ -303,7 +368,7 @@ class ClusterMotor:
303368
for i, (motor, pos) in enumerate(zip(self.motors, self.positions)):
304369
motor_body = patches.Circle(
305370
(pos[0], pos[1]),
306-
radius=motor.grain_outer_radius,
371+
radius=getattr(motor, "grain_outer_radius", motor.nozzle_radius/2),
307372
facecolor="dimgrey",
308373
edgecolor="black",
309374
linewidth=1.5,

0 commit comments

Comments
 (0)