Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions rocketpy/motors/cluster_motor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
# pylint: disable=invalid-name
import matplotlib.pyplot as plt
import numpy as np
from rocketpy import Function
from rocketpy.motors import Motor


class ClusterMotor(Motor):
"""
A class representing a cluster of N identical motors arranged symmetrically.

This class aggregates the physical properties (thrust, mass, inertia) of
multiple motors using the Parallel Axis Theorem (Huygens-Steiner theorem).

Attributes
----------
motor : SolidMotor
The single motor instance used in the cluster.
number : int
The number of motors in the cluster.
radius : float
The radial distance from the rocket's central axis to the center of each motor.
"""

def __init__(self, motor, number, radius):
"""
Initialize the ClusterMotor.

Parameters
----------
motor : SolidMotor
The base motor to be clustered.
number : int
Number of motors. Must be >= 2.
radius : float
Distance from center of rocket to center of motor (m).
"""
if not isinstance(number, int):
raise TypeError(f"number must be an int, got {type(number).__name__}")
if number < 2:
raise ValueError("number must be >= 2 for a ClusterMotor")
if not isinstance(radius, (int, float)):
raise TypeError(
f"radius must be a real number, got {type(radius).__name__}"
)
if radius < 0:
raise ValueError("radius must be non-negative")

self.motor = motor
self.number = number
self.radius = float(radius)
dry_inertia_cluster = self._calculate_dry_inertia()

# Use a thrust source scaled by the number of motors so that
# all thrust-derived quantities computed by the base Motor class
# correspond to the full cluster rather than a single motor.
scaled_thrust_source = motor.thrust * number

super().__init__(
thrust_source=scaled_thrust_source,
nozzle_radius=motor.nozzle_radius,
burn_time=motor.burn_time,
dry_mass=motor.dry_mass * number,
dry_inertia=dry_inertia_cluster,
center_of_dry_mass_position=motor.center_of_dry_mass_position,
coordinate_system_orientation=motor.coordinate_system_orientation,
interpolation_method="linear",
)

self._setup_grain_properties()
self._propellant_mass = self.motor.propellant_mass * self.number
self._propellant_initial_mass = self.number * self.motor.propellant_initial_mass
self._center_of_propellant_mass = self.motor.center_of_propellant_mass
self._evaluate_propellant_inertia()

def _evaluate_propellant_inertia(self):
"""Calculates the dynamic inertia of the propellant using Steiner's theorem."""
Ixx_term1 = self.motor.propellant_I_11 * self.number
Ixx_term2 = self.motor.propellant_mass * (0.5 * self.number * self.radius**2)
self._propellant_I_11 = Ixx_term1 + Ixx_term2
self._propellant_I_22 = self._propellant_I_11

Izz_term1 = self.motor.propellant_I_33 * self.number
Izz_term2 = self.motor.propellant_mass * (self.number * self.radius**2)
self._propellant_I_33 = Izz_term1 + Izz_term2

zero_func = Function(0)
self._propellant_I_12 = zero_func
self._propellant_I_13 = zero_func
self._propellant_I_23 = zero_func

def _setup_grain_properties(self):
"""Copies the grain properties from the base motor."""
self.throat_radius = self.motor.throat_radius
self.grain_number = self.motor.grain_number
self.grain_density = self.motor.grain_density
self.grain_outer_radius = self.motor.grain_outer_radius
self.grain_initial_inner_radius = self.motor.grain_initial_inner_radius
self.grain_initial_height = self.motor.grain_initial_height
self.grains_center_of_mass_position = self.motor.grains_center_of_mass_position

@property
def thrust(self):
return self._thrust

@thrust.setter
def thrust(self, value):
self._thrust = value

@property
def propellant_mass(self):
return self._propellant_mass

@propellant_mass.setter
def propellant_mass(self, value):
self._propellant_mass = value

@property
def propellant_initial_mass(self):
return self._propellant_initial_mass

@propellant_initial_mass.setter
def propellant_initial_mass(self, value):
self._propellant_initial_mass = value

@property
def center_of_propellant_mass(self):
return self._center_of_propellant_mass

@center_of_propellant_mass.setter
def center_of_propellant_mass(self, value):
self._center_of_propellant_mass = value

@property
def propellant_I_11(self):
return self._propellant_I_11

@propellant_I_11.setter
def propellant_I_11(self, value):
self._propellant_I_11 = value

@property
def propellant_I_22(self):
return self._propellant_I_22

@propellant_I_22.setter
def propellant_I_22(self, value):
self._propellant_I_22 = value

@property
def propellant_I_33(self):
return self._propellant_I_33

@propellant_I_33.setter
def propellant_I_33(self, value):
self._propellant_I_33 = value

@property
def propellant_I_12(self):
return self._propellant_I_12

@propellant_I_12.setter
def propellant_I_12(self, value):
self._propellant_I_12 = value

@property
def propellant_I_13(self):
return self._propellant_I_13

@propellant_I_13.setter
def propellant_I_13(self, value):
self._propellant_I_13 = value

@property
def propellant_I_23(self):
return self._propellant_I_23

@propellant_I_23.setter
def propellant_I_23(self, value):
self._propellant_I_23 = value

@property
def exhaust_velocity(self):
return self.motor.exhaust_velocity

def _calculate_dry_inertia(self):
Ixx_loc = self.motor.dry_I_11
Iyy_loc = self.motor.dry_I_22
Izz_loc = self.motor.dry_I_33
m_dry = self.motor.dry_mass

Izz_cluster = self.number * Izz_loc + self.number * m_dry * (self.radius**2)
Ixx_cluster = self.number * Ixx_loc + (self.number / 2) * m_dry * (
self.radius**2
)
Iyy_cluster = self.number * Iyy_loc + (self.number / 2) * m_dry * (
self.radius**2
)

return (Ixx_cluster, Iyy_cluster, Izz_cluster)

def info(self, *args, **kwargs):
print("Cluster Configuration:")
print(f" - Motors: {self.number} x {type(self.motor).__name__}")
print(f" - Radial Distance: {self.radius} m")
return self.motor.info(*args, **kwargs)

def draw_cluster_layout(self, rocket_radius=None, show=True):
"""Draw the geometric layout of the clustered motors."""
fig, ax = plt.subplots(figsize=(6, 6))
ax.plot(0, 0, "k+", markersize=10, label="Central axis")
if rocket_radius:
rocket_tube = plt.Circle(
(0, 0),
rocket_radius,
color="black",
fill=False,
linestyle="--",
linewidth=2,
label="Rocket",
)
ax.add_patch(rocket_tube)
limit = rocket_radius * 1.2
else:
limit = self.radius * 2
self._draw_engines(ax)
ax.set_aspect("equal", "box")
ax.set_xlim(-limit, limit)
ax.set_ylim(-limit, limit)
ax.set_xlabel("Position X (m)")
ax.set_ylabel("Position Y (m)")
ax.set_title(f"Cluster Configuration : {self.number} engines")
ax.grid(True, linestyle=":", alpha=0.6)
ax.legend(loc="upper right")
if show:
plt.show()
return fig, ax

def _draw_engines(self, ax):
"""Draws the individual engines of the cluster."""
motor_outer_radius = self.grain_outer_radius
angles = np.linspace(0, 2 * np.pi, self.number, endpoint=False)

for i, angle in enumerate(angles):
x = self.radius * np.cos(angle)
y = self.radius * np.sin(angle)
motor_circle = plt.Circle(
(x, y),
motor_outer_radius,
color="red",
alpha=0.5,
label="Engine" if i == 0 else "",
)
ax.add_patch(motor_circle)
ax.text(
x,
y,
str(i + 1),
color="white",
ha="center",
va="center",
fontweight="bold",
)
87 changes: 56 additions & 31 deletions rocketpy/plots/rocket_plots.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import matplotlib.pyplot as plt
import numpy as np

from rocketpy.motors import EmptyMotor, HybridMotor, LiquidMotor, SolidMotor
from rocketpy.motors import HybridMotor, LiquidMotor, SolidMotor
from rocketpy.rocket.aero_surface import Fins, NoseCone, Tail
from rocketpy.rocket.aero_surface.generic_surface import GenericSurface

Expand Down Expand Up @@ -420,57 +420,82 @@ def _draw_tubes(self, ax, drawn_surfaces, vis_args):
def _draw_motor(self, last_radius, last_x, ax, vis_args):
"""Draws the motor from motor patches"""
total_csys = self.rocket._csys * self.rocket.motor._csys
is_cluster = hasattr(self.rocket.motor, "number")
base_motor = self.rocket.motor.motor if is_cluster else self.rocket.motor

if is_cluster:
angles = np.linspace(0, 2 * np.pi, self.rocket.motor.number, endpoint=False)
y_offsets = self.rocket.motor.radius * np.cos(angles)
else:
y_offsets = [0]
nozzle_position = (
self.rocket.motor_position + self.rocket.motor.nozzle_position * total_csys
self.rocket.motor_position + base_motor.nozzle_position * total_csys
)

# Get motor patches translated to the correct position
motor_patches = self._generate_motor_patches(total_csys, ax)

# Draw patches
if not isinstance(self.rocket.motor, EmptyMotor):
# Add nozzle last so it is in front of the other patches
nozzle = self.rocket.motor.plots._generate_nozzle(
translate=(nozzle_position, 0), csys=self.rocket._csys
)
motor_patches += [nozzle]
if type(self.rocket.motor).__name__ != "EmptyMotor":
for y_off in y_offsets:
nozzle = base_motor.plots._generate_nozzle(
translate=(nozzle_position, y_off), csys=self.rocket._csys
)
if y_off != y_offsets[0]:
nozzle.set_label("_nolegend_")
motor_patches.append(nozzle)

outline = self.rocket.motor.plots._generate_motor_region(
outline = base_motor.plots._generate_motor_region(
list_of_patches=motor_patches
)
# add outline first so it is behind the other patches
ax.add_patch(outline)
if not is_cluster:
ax.add_patch(outline)

for patch in motor_patches:
if is_cluster:
patch.set_alpha(0.6)
ax.add_patch(patch)

self._draw_nozzle_tube(last_radius, last_x, nozzle_position, ax, vis_args)

def _generate_motor_patches(self, total_csys, ax): # pylint: disable=unused-argument
"""Generates motor patches for drawing"""
motor_patches = []

if isinstance(self.rocket.motor, SolidMotor):
is_cluster = hasattr(self.rocket.motor, "number")
base_motor = self.rocket.motor.motor if is_cluster else self.rocket.motor

if isinstance(base_motor, SolidMotor):
y_offsets = (
self.rocket.motor.radius
* np.cos(
np.linspace(0, 2 * np.pi, self.rocket.motor.number, endpoint=False)
)
if is_cluster
else [0]
)
grains_cm_position = (
self.rocket.motor_position
+ self.rocket.motor.grains_center_of_mass_position * total_csys
)
ax.scatter(
grains_cm_position,
0,
color="brown",
label="Grains Center of Mass",
s=8,
zorder=10,
+ base_motor.grains_center_of_mass_position * total_csys
)
for y_off in y_offsets:
ax.scatter(
grains_cm_position,
y_off,
color="brown",
label="Grains Center of Mass" if y_off == y_offsets[0] else "",
s=8,
zorder=10,
)

chamber = self.rocket.motor.plots._generate_combustion_chamber(
translate=(grains_cm_position, 0), label=None
)
grains = self.rocket.motor.plots._generate_grains(
translate=(grains_cm_position, 0)
)
chamber = base_motor.plots._generate_combustion_chamber(
translate=(grains_cm_position, y_off), label=None
)
grains = base_motor.plots._generate_grains(
translate=(grains_cm_position, y_off)
)
if y_off != y_offsets[0]:
for grain in grains:
grain.set_label("_nolegend_")

motor_patches += [chamber, *grains]
motor_patches += [chamber, *grains]

elif isinstance(self.rocket.motor, HybridMotor):
grains_cm_position = (
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/environment/test_environment.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import time
from datetime import date, datetime, timezone
from unittest.mock import patch

import numpy as np
import pytest


Expand Down
Loading