@@ -16,12 +16,45 @@ from rocketpy.tools import (
1616class 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