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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Attention: The newest changes should be on top -->

### Added

- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)

### Changed

### Fixed
Expand Down Expand Up @@ -73,6 +75,7 @@ Attention: The newest changes should be on top -->
## [v1.10.0] - 2025-05-16

### Added

- ENH: Support for ND arithmetic in Function class. [#810] (https://github.com/RocketPy-Team/RocketPy/pull/810)
- ENH: allow users to provide custom samplers [#803](https://github.com/RocketPy-Team/RocketPy/pull/803)
- ENH: Implement Multivariate Rejection Sampling (MRS) [#738] (https://github.com/RocketPy-Team/RocketPy/pull/738)
Expand Down
11 changes: 11 additions & 0 deletions rocketpy/motors/hybrid_motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,12 @@ class HybridMotor(Motor):
HybridMotor.reference_pressure : int, float
Atmospheric pressure in Pa at which the thrust data was recorded.
It will allow to obtain the net thrust in the Flight class.
SolidMotor.only_radial_burn : bool
If True, grain regression is restricted to radial burn only (inner radius growth).
Grain length remains constant throughout the burn. Default is False.
"""

# pylint: disable=too-many-arguments
def __init__( # pylint: disable=too-many-arguments
self,
thrust_source,
Expand All @@ -216,6 +220,7 @@ def __init__( # pylint: disable=too-many-arguments
interpolation_method="linear",
coordinate_system_orientation="nozzle_to_combustion_chamber",
reference_pressure=None,
only_radial_burn=True,
):
"""Initialize Motor class, process thrust curve and geometrical
parameters and store results.
Expand Down Expand Up @@ -313,6 +318,11 @@ class Function. Thrust units are Newtons.
"nozzle_to_combustion_chamber".
reference_pressure : int, float, optional
Atmospheric pressure in Pa at which the thrust data was recorded.
only_radial_burn : boolean, optional
If True, inhibits the grain from burning axially, only computing
radial burn. If False, allows the grain to also burn
axially. May be useful for axially inhibited grains or hybrid motors.
Default is False.
Returns
-------
Expand Down Expand Up @@ -364,6 +374,7 @@ class Function. Thrust units are Newtons.
interpolation_method,
coordinate_system_orientation,
reference_pressure,
only_radial_burn,
)

self.positioned_tanks = self.liquid.positioned_tanks
Expand Down
139 changes: 96 additions & 43 deletions rocketpy/motors/solid_motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ class SolidMotor(Motor):
SolidMotor.reference_pressure : int, float
Atmospheric pressure in Pa at which the thrust data was recorded.
It will allow to obtain the net thrust in the Flight class.
SolidMotor.only_radial_burn : bool
If True, grain regression is restricted to radial burn only (inner radius growth).
Grain length remains constant throughout the burn. Default is False.
"""

# pylint: disable=too-many-arguments
Expand All @@ -217,6 +220,7 @@ def __init__(
interpolation_method="linear",
coordinate_system_orientation="nozzle_to_combustion_chamber",
reference_pressure=None,
only_radial_burn=False,
):
"""Initialize Motor class, process thrust curve and geometrical
parameters and store results.
Expand Down Expand Up @@ -314,11 +318,19 @@ class Function. Thrust units are Newtons.
"nozzle_to_combustion_chamber".
reference_pressure : int, float, optional
Atmospheric pressure in Pa at which the thrust data was recorded.
only_radial_burn : boolean, optional
If True, inhibits the grain from burning axially, only computing
radial burn. If False, allows the grain to also burn
axially. May be useful for axially inhibited grains or hybrid motors.
Default is False.

Returns
-------
None
"""
# Store before calling super().__init__() since it calls evaluate_geometry()
self.only_radial_burn = only_radial_burn

super().__init__(
thrust_source=thrust_source,
dry_inertia=dry_inertia,
Expand Down Expand Up @@ -500,17 +512,25 @@ def geometry_dot(t, y):

# Compute state vector derivative
grain_inner_radius, grain_height = y
burn_area = (
2
* np.pi
* (
grain_outer_radius**2
- grain_inner_radius**2
+ grain_inner_radius * grain_height
if self.only_radial_burn:
burn_area = 2 * np.pi * (grain_inner_radius * grain_height)

grain_inner_radius_derivative = -volume_diff / burn_area
grain_height_derivative = 0 # Set to zero to disable axial burning

else:
burn_area = (
2
* np.pi
* (
grain_outer_radius**2
- grain_inner_radius**2
+ grain_inner_radius * grain_height
)
)
)
grain_inner_radius_derivative = -volume_diff / burn_area
grain_height_derivative = -2 * grain_inner_radius_derivative

grain_inner_radius_derivative = -volume_diff / burn_area
grain_height_derivative = -2 * grain_inner_radius_derivative

return [grain_inner_radius_derivative, grain_height_derivative]

Expand All @@ -521,32 +541,55 @@ def geometry_jacobian(t, y):

# Compute jacobian
grain_inner_radius, grain_height = y
factor = volume_diff / (
2
* np.pi
* (
grain_outer_radius**2
- grain_inner_radius**2
+ grain_inner_radius * grain_height
if self.only_radial_burn:
factor = volume_diff / (
2 * np.pi * (grain_inner_radius * grain_height) ** 2
)
** 2
)
inner_radius_derivative_wrt_inner_radius = factor * (
grain_height - 2 * grain_inner_radius
)
inner_radius_derivative_wrt_height = factor * grain_inner_radius
height_derivative_wrt_inner_radius = (
-2 * inner_radius_derivative_wrt_inner_radius
)
height_derivative_wrt_height = -2 * inner_radius_derivative_wrt_height

return [
[
inner_radius_derivative_wrt_inner_radius,
inner_radius_derivative_wrt_height,
],
[height_derivative_wrt_inner_radius, height_derivative_wrt_height],
]
inner_radius_derivative_wrt_inner_radius = factor * (
grain_height - 2 * grain_inner_radius
)
inner_radius_derivative_wrt_height = 0
height_derivative_wrt_inner_radius = 0
height_derivative_wrt_height = 0
# Height is a constant, so all the derivatives with respect to it are set to zero

return [
[
inner_radius_derivative_wrt_inner_radius,
inner_radius_derivative_wrt_height,
],
[height_derivative_wrt_inner_radius, height_derivative_wrt_height],
]

else:
factor = volume_diff / (
2
* np.pi
* (
grain_outer_radius**2
- grain_inner_radius**2
+ grain_inner_radius * grain_height
)
** 2
)

inner_radius_derivative_wrt_inner_radius = factor * (
grain_height - 2 * grain_inner_radius
)
inner_radius_derivative_wrt_height = factor * grain_inner_radius
height_derivative_wrt_inner_radius = (
-2 * inner_radius_derivative_wrt_inner_radius
)
height_derivative_wrt_height = -2 * inner_radius_derivative_wrt_height

return [
[
inner_radius_derivative_wrt_inner_radius,
inner_radius_derivative_wrt_height,
],
[height_derivative_wrt_inner_radius, height_derivative_wrt_height],
]

def terminate_burn(t, y): # pylint: disable=unused-argument
end_function = (self.grain_outer_radius - y[0]) * y[1]
Expand Down Expand Up @@ -597,16 +640,24 @@ def burn_area(self):
burn_area : Function
Function representing the burn area progression with the time.
"""
burn_area = (
2
* np.pi
* (
self.grain_outer_radius**2
- self.grain_inner_radius**2
+ self.grain_inner_radius * self.grain_height
if self.only_radial_burn:
burn_area = (
2
* np.pi
* (self.grain_inner_radius * self.grain_height)
* self.grain_number
)
else:
burn_area = (
2
* np.pi
* (
self.grain_outer_radius**2
- self.grain_inner_radius**2
+ self.grain_inner_radius * self.grain_height
)
* self.grain_number
)
* self.grain_number
)
return burn_area

@funcify_method("Time (s)", "burn rate (m/s)")
Expand Down Expand Up @@ -778,6 +829,7 @@ def to_dict(self, **kwargs):
"grain_initial_height": self.grain_initial_height,
"grain_separation": self.grain_separation,
"grains_center_of_mass_position": self.grains_center_of_mass_position,
"only_radial_burn": self.only_radial_burn,
}
)

Expand Down Expand Up @@ -827,4 +879,5 @@ def from_dict(cls, data):
interpolation_method=data["interpolate"],
coordinate_system_orientation=data["coordinate_system_orientation"],
reference_pressure=data.get("reference_pressure"),
only_radial_burn=data.get("only_radial_burn", False),
)
4 changes: 2 additions & 2 deletions tests/fixtures/motor/hybrid_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


@pytest.fixture
def hybrid_motor(spherical_oxidizer_tank):
def hybrid_motor(oxidizer_tank):
"""An example of a hybrid motor with spherical oxidizer
tank and fuel grains.

Expand Down Expand Up @@ -35,6 +35,6 @@ def hybrid_motor(spherical_oxidizer_tank):
grains_center_of_mass_position=-0.1,
)

motor.add_tank(spherical_oxidizer_tank, position=0.3)
motor.add_tank(oxidizer_tank, position=0.3)

return motor
42 changes: 40 additions & 2 deletions tests/integration/motors/test_hybridmotor.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# pylint: disable=unused-argument
from unittest.mock import patch

import numpy as np

@patch("matplotlib.pyplot.show")

@patch("matplotlib.pyplot.show") # pylint: disable=unused-argument
def test_hybrid_motor_info(mock_show, hybrid_motor):
"""Tests the HybridMotor.all_info() method.

Expand All @@ -15,3 +16,40 @@ def test_hybrid_motor_info(mock_show, hybrid_motor):
"""
assert hybrid_motor.info() is None
assert hybrid_motor.all_info() is None


def test_hybrid_motor_only_radial_burn_behavior(hybrid_motor):
"""
Test if only_radial_burn flag in HybridMotor propagates to its SolidMotor
and affects burn_area calculation.
"""
motor = hybrid_motor

# Activates the radial burning
motor.solid.only_radial_burn = True
assert motor.solid.only_radial_burn is True

# Calculates the expected initial area
burn_area_radial = (
2
* np.pi
* (motor.solid.grain_inner_radius(0) * motor.solid.grain_height(0))
* motor.solid.grain_number
)

assert np.isclose(motor.solid.burn_area(0), burn_area_radial, atol=1e-12)

# Deactivates the radial burning and recalculate the geometry
motor.solid.only_radial_burn = False
motor.solid.evaluate_geometry()
assert motor.solid.only_radial_burn is False

# In this case the burning area also considers the bases of the grain
inner_radius = motor.solid.grain_inner_radius(0)
outer_radius = motor.solid.grain_outer_radius
burn_area_total = (
burn_area_radial
+ 2 * np.pi * (outer_radius**2 - inner_radius**2) * motor.solid.grain_number
)
assert np.isclose(motor.solid.burn_area(0), burn_area_total, atol=1e-12)
assert motor.solid.burn_area(0) > burn_area_radial
58 changes: 58 additions & 0 deletions tests/integration/motors/test_solid_motor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import numpy as np


def test_only_radial_burn_parameter_effect(cesaroni_m1670):
"""Tests the effect of the only_radial_burn parameter on burn area
calculation. When enabled, the burn area should only account for
the radial surface of the grains (no axial regression).

Parameters
----------
cesaroni_m1670 : rocketpy.SolidMotor
The SolidMotor object used in the test.
"""
motor = cesaroni_m1670
motor.only_radial_burn = True
assert motor.only_radial_burn

# When only_radial_burn is active, burn_area should consider only radial area
burn_area_radial = (
2
* np.pi
* motor.grain_inner_radius(0)
* motor.grain_height(0)
* motor.grain_number
)
assert np.isclose(motor.burn_area(0), burn_area_radial, atol=1e-12)


def test_evaluate_geometry_updates_properties(cesaroni_m1670):
"""Tests if the grain geometry evaluation correctly updates SolidMotor
properties after instantiation. It ensures that grain geometry
functions are created and behave as expected.

Parameters
----------
cesaroni_m1670 : rocketpy.SolidMotor
The SolidMotor object used in the test.
"""
motor = cesaroni_m1670

assert hasattr(motor, "grain_inner_radius")
assert hasattr(motor, "grain_height")

# Checks if the domain of grain_inner_radius function is consistent
times = motor.grain_inner_radius.x_array
values = motor.grain_inner_radius.y_array

# expected initial time
assert times[0] == 0

# expected initial inner radius
assert values[0] == motor.grain_initial_inner_radius

# final inner radius should be less or equal than outer radius
assert values[-1] <= motor.grain_outer_radius

# evaluate at intermediate time
assert isinstance(motor.grain_inner_radius(0.5), float)
Loading
Loading