Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f048c56
Functions associated to the regression updated #801
caioessouza Apr 24, 2025
4f32f88
super() corrected and dict functions updated #801
caioessouza Apr 30, 2025
550e149
Last update #801
caioessouza May 3, 2025
7a4267a
Update CHANGELOG.md
caioessouza May 9, 2025
95970b9
Merge branch 'develop' into enh/enable-only-radial-burning
MateusStano May 16, 2025
3287138
Only radial burning enabled on hybrid class #801
caioessouza May 20, 2025
02de507
Merge branch 'enh/enable-only-radial-burning' of https://github.com/c…
caioessouza May 20, 2025
d97ba24
only_radial_burn on hybrid corrected
caioessouza May 30, 2025
0cb0eb5
Tests created
caioessouza Aug 13, 2025
8de2ab4
Corrections made based in the review
caioessouza Aug 14, 2025
e681877
Merge branch 'develop' into enh/enable-only-radial-burning
caioessouza Aug 14, 2025
4e4df54
Tests approved
caioessouza Oct 2, 2025
c941070
Merge branch 'enh/enable-only-radial-burning' of https://github.com/c…
caioessouza Oct 2, 2025
2b3a15c
Merge branch 'develop' into enh/enable-only-radial-burning
caioessouza Oct 2, 2025
79e2a1c
Unit tests
caioessouza Oct 2, 2025
3ecf1d7
Merge branch 'enh/enable-only-radial-burning' of https://github.com/c…
caioessouza Oct 2, 2025
65dfa23
Merge branch 'develop' into enh/enable-only-radial-burning
caioessouza Oct 2, 2025
79c066c
Integration mock_show correction
caioessouza Oct 2, 2025
32f7711
Merge branch 'enh/enable-only-radial-burning' of https://github.com/c…
caioessouza Oct 2, 2025
f92cb64
mock_show returned to unit tests
caioessouza Oct 2, 2025
d9d99dd
Integration problem solved
caioessouza Oct 2, 2025
cb3c3a2
Ruff run again
caioessouza Oct 3, 2025
8ebbc90
Sugestions Update
caioessouza Oct 3, 2025
292ab84
Ruff format run
caioessouza Oct 3, 2025
b99afb1
Removed @property
caioessouza Oct 3, 2025
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Attention: The newest changes should be on top -->

### Added
- ENH: Enable only radial burning [#815](https://github.com/RocketPy-Team/RocketPy/pull/815)
- ENH: Controller (AirBrakes) and Sensors Encoding [#849] (https://github.com/RocketPy-Team/RocketPy/pull/849)
- EHN: Addition of ensemble variable to ECMWF dictionaries [#842] (https://github.com/RocketPy-Team/RocketPy/pull/842)
- ENH: Added Crop and Clip Methods to Function Class [#817](https://github.com/RocketPy-Team/RocketPy/pull/817)
Expand Down
2 changes: 2 additions & 0 deletions rocketpy/motors/hybrid_motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There can be made some discussion here on the severity of it, but this is likely a breaking change: the default of this attribute should be False, otherwise all the current code with hybrid motors would have its behavior changed. Do you agree @Gui-FernandesBR ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this change would break anything, but I think it can change the propellant mass evolution with time. Anyway, this behaviour is more similar to reality, so in my conception even if it changes, it's not necessarily a bad thing. Do you agree @Gui-FernandesBR @MateusStano ? If you think it's a problem, I can change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it changes results, and I understand it changes for the better.

@phmbressan don't you think we could proceed with this?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parameter needs to be documented in the class and __init__ docstrings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check, please review again to make sure everything is ok

):
"""Initialize Motor class, process thrust curve and geometrical
parameters and store results.
Expand Down Expand Up @@ -364,6 +365,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
136 changes: 93 additions & 43 deletions rocketpy/motors/solid_motor.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,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,6 +315,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 @@ -353,6 +359,9 @@ class Function. Thrust units are Newtons.
)
self.grain_initial_mass = self.grain_density * self.grain_initial_volume

# Burn surface definition
self.only_radial_burn = only_radial_burn

self.evaluate_geometry()

# Initialize plots and prints object
Expand Down Expand Up @@ -500,17 +509,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 +538,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 +637,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 +826,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 +876,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):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't oppose to it if needed, but any particular reason for changing the fixture name?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spherical_oxidizer_tank fixture is broken. It's giving negative liquid heights. It need to be fixed in another issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caioessouza what issue are you referring to?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no issue for it yet, but the spherical_oxidizer_tank fixture is returning negative liquid heights for this burning duration.

"""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
16 changes: 16 additions & 0 deletions tests/integration/test_solidmotor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from unittest.mock import patch


@patch("matplotlib.pyplot.show")
def test_solid_motor_info(mock_show, cesaroni_m1670):
"""Tests the SolidMotor.all_info() method.

Parameters
----------
mock_show : mock
Mock of the matplotlib.pyplot.show function.
cesaroni_m1670 : rocketpy.SolidMotor
The SolidMotor object to be used in the tests.
"""
assert cesaroni_m1670.info() is None
assert cesaroni_m1670.all_info() is None
61 changes: 49 additions & 12 deletions tests/unit/test_hybridmotor.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def test_hybrid_motor_basic_parameters(hybrid_motor):
assert hybrid_motor.liquid.positioned_tanks[0]["position"] == 0.3


def test_hybrid_motor_thrust_parameters(hybrid_motor, spherical_oxidizer_tank):
def test_hybrid_motor_thrust_parameters(hybrid_motor, oxidizer_tank):
"""Tests the HybridMotor class thrust parameters.

Parameters
Expand All @@ -77,13 +77,13 @@ def test_hybrid_motor_thrust_parameters(hybrid_motor, spherical_oxidizer_tank):
* GRAIN_INITIAL_HEIGHT
* GRAIN_NUMBER
)
initial_oxidizer_mass = spherical_oxidizer_tank.fluid_mass(0)
initial_oxidizer_mass = oxidizer_tank.fluid_mass(0)
initial_mass = initial_grain_mass + initial_oxidizer_mass

expected_exhaust_velocity = expected_total_impulse / initial_mass
expected_mass_flow_rate = -expected_thrust / expected_exhaust_velocity
expected_grain_mass_flow_rate = (
expected_mass_flow_rate - spherical_oxidizer_tank.net_mass_flow_rate
expected_mass_flow_rate - oxidizer_tank.net_mass_flow_rate
)

assert pytest.approx(hybrid_motor.thrust.y_array) == expected_thrust.y_array
Expand All @@ -100,7 +100,7 @@ def test_hybrid_motor_thrust_parameters(hybrid_motor, spherical_oxidizer_tank):
) == expected_grain_mass_flow_rate(t)


def test_hybrid_motor_center_of_mass(hybrid_motor, spherical_oxidizer_tank):
def test_hybrid_motor_center_of_mass(hybrid_motor, oxidizer_tank):
"""Tests the HybridMotor class center of mass.

Parameters
Expand All @@ -110,25 +110,25 @@ def test_hybrid_motor_center_of_mass(hybrid_motor, spherical_oxidizer_tank):
spherical_oxidizer_tank : rocketpy.SphericalTank
The SphericalTank object to be used in the tests.
"""
oxidizer_mass = spherical_oxidizer_tank.fluid_mass
oxidizer_mass = oxidizer_tank.fluid_mass
grain_mass = hybrid_motor.solid.propellant_mass

propellant_balance = grain_mass * GRAINS_CENTER_OF_MASS_POSITION + oxidizer_mass * (
OXIDIZER_TANK_POSITION + spherical_oxidizer_tank.center_of_mass
OXIDIZER_TANK_POSITION + oxidizer_tank.center_of_mass
)
balance = propellant_balance + DRY_MASS * CENTER_OF_DRY_MASS

propellant_center_of_mass = propellant_balance / (grain_mass + oxidizer_mass)
center_of_mass = balance / (grain_mass + oxidizer_mass + DRY_MASS)

for t in np.linspace(0, 100, 100):
for t in np.linspace(0, BURN_TIME, 100):
assert pytest.approx(
hybrid_motor.center_of_propellant_mass(t)
) == propellant_center_of_mass(t)
assert pytest.approx(hybrid_motor.center_of_mass(t)) == center_of_mass(t)


def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank):
def test_hybrid_motor_inertia(hybrid_motor, oxidizer_tank):
"""Tests the HybridMotor class inertia.

Parameters
Expand All @@ -138,8 +138,8 @@ def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank):
spherical_oxidizer_tank : rocketpy.SphericalTank
The SphericalTank object to be used in the tests.
"""
oxidizer_mass = spherical_oxidizer_tank.fluid_mass
oxidizer_inertia = spherical_oxidizer_tank.inertia
oxidizer_mass = oxidizer_tank.fluid_mass
oxidizer_inertia = oxidizer_tank.inertia
grain_mass = hybrid_motor.solid.propellant_mass
grain_inertia = hybrid_motor.solid.propellant_I_11
propellant_mass = oxidizer_mass + grain_mass
Expand All @@ -153,7 +153,7 @@ def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank):
oxidizer_mass
* (
OXIDIZER_TANK_POSITION
+ spherical_oxidizer_tank.center_of_mass
+ oxidizer_tank.center_of_mass
- hybrid_motor.center_of_propellant_mass
)
** 2
Expand All @@ -170,9 +170,46 @@ def test_hybrid_motor_inertia(hybrid_motor, spherical_oxidizer_tank):
+ DRY_MASS * (-hybrid_motor.center_of_mass + CENTER_OF_DRY_MASS) ** 2
)

for t in np.linspace(0, 100, 100):
for t in np.linspace(0, BURN_TIME, 100):
assert pytest.approx(hybrid_motor.propellant_I_11(t)) == propellant_inertia(t)
assert pytest.approx(hybrid_motor.I_11(t)) == inertia(t)

# Assert cylindrical symmetry
assert pytest.approx(hybrid_motor.propellant_I_22(t)) == propellant_inertia(t)


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
Comment on lines +189 to +190
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite dangerous. I did not test nor exhaustively review the code, but here are some of my comments that I believe are relevant (feel free to correct me on that) if you want to support setting (post instantiation) this attribute:

  • The SolidMotor.evaluate_geometry method also sets some of the instance attributes, such as self.grain_burn_out and self.grain_inner_radius;
  • Simply toggling the attribute only_radial_burn would not change the values of those attributes, therefore their old values would be kept.

On the top of my head, I see two possible solutions here:

  1. Supporting this toggling after instantiation in which other values of the class should be adapted is the task of a setter:
class SolidMotor(Motor):
    def __init__(self, ..., only_radial_burn=False):
        ...
        self._only_radial_burn = only_radial_burn

    @property
    def only_radial_burn(self):
        return self._only_radial_burn

    @only_radial_burn.setter
    def only_radial_burn(self, value):
        self._only_radial_burn = value
        self.evaluate_geometry()

class HybridMotor(Motor):
    def __init__(self, ..., only_radial_burn=False):
        ...
        self._only_radial_burn = only_radial_burn

    @property
    def only_radial_burn(self):
        return self._only_radial_burn

    @only_radial_burn.setter
    def only_radial_burn(self, value):
        self.solid.only_radial_burn = value
        reset_funcified_method(self)
  1. Alternative simpler solution: another possibility is to disallow the toggling of this attribute after instantiation. This is possible by using a property with no setter. In this way, the radial burn can only be selected on instantiation.
class SolidMotor(Motor):
    def __init__(self, ..., only_radial_burn=False):
        ...
        self._only_radial_burn = only_radial_burn

    @property
    def only_radial_burn(self):
        return self._only_radial_burn

@caioessouza could you confirm this is the case, i.e., toggling this attribute would cause the defined attributes from evaluate_geometry incorrectly keep their old values? If I understood things correctly, the tests for this new parameter did not catch that, which means they have room for improvement.

I, personally, have a slight preference for option 2, since it avoids complicating more this already delicate interaction between the motor types. Do you have any suggestions @MateusStano or @Gui-FernandesBR ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the best option is to make only_radial_burn not mutable.

This would make life much easier.

If you want to modify the value of only_radial_burn after instanciating the object, you should instantiate another object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just implemented option 2. I agree that makes more sense, because it's a characteristic of each motor, so it's not mutable in reality anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I added this

class SolidMotor(Motor):
def init(self, ..., only_radial_burn=False):
...
self._only_radial_burn = only_radial_burn

@property
def only_radial_burn(self):
    return self._only_radial_burn

It broke a lot of tests, maybe I've done something wrong.
Can you do this part? @phmbressan


# 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
Loading