diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b394e4fa..12f12111e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +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: Added Crop and Clip Methods to Function Class [#817](https://github.com/RocketPy-Team/RocketPy/pull/817) - DOC: Add Flight class usage documentation and update index [#841](https://github.com/RocketPy-Team/RocketPy/pull/841) - ENH: Discretized and No-Pickle Encoding Options [#827] (https://github.com/RocketPy-Team/RocketPy/pull/827) diff --git a/rocketpy/motors/hybrid_motor.py b/rocketpy/motors/hybrid_motor.py index 7cb28670c..b65bfb9b0 100644 --- a/rocketpy/motors/hybrid_motor.py +++ b/rocketpy/motors/hybrid_motor.py @@ -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, ): """Initialize Motor class, process thrust curve and geometrical parameters and store results. @@ -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 diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py index 8a00eeec9..ecfb23cb5 100644 --- a/rocketpy/motors/solid_motor.py +++ b/rocketpy/motors/solid_motor.py @@ -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. @@ -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 ------- @@ -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 @@ -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] @@ -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] @@ -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)") @@ -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, } ) @@ -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), ) diff --git a/tests/integration/test_solidmotor.py b/tests/integration/test_solidmotor.py new file mode 100644 index 000000000..3cd58a345 --- /dev/null +++ b/tests/integration/test_solidmotor.py @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/unit/test_hybridmotor.py b/tests/unit/test_hybridmotor.py index ef03a1998..0c79eaac5 100644 --- a/tests/unit/test_hybridmotor.py +++ b/tests/unit/test_hybridmotor.py @@ -121,7 +121,7 @@ def test_hybrid_motor_center_of_mass(hybrid_motor, spherical_oxidizer_tank): 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) @@ -170,9 +170,45 @@ 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 + + # 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 \ No newline at end of file diff --git a/tests/unit/test_solidmotor.py b/tests/unit/test_solidmotor.py index 3f829d222..5e8906b3a 100644 --- a/tests/unit/test_solidmotor.py +++ b/tests/unit/test_solidmotor.py @@ -279,3 +279,32 @@ def test_reshape_thrust_curve_asserts_resultant_thrust_curve_correct( assert thrust_reshaped[1][1] == 100 * (tuple_parametric[1] / 7539.1875) assert thrust_reshaped[7][1] == 2034 * (tuple_parametric[1] / 7539.1875) + +def test_only_radial_burn_parameter_effect(cesaroni_m1670): + # Tests if the only_radial_burn flag is properly set + motor = cesaroni_m1670 + motor.only_radial_burn = True + assert motor.only_radial_burn is True + + # 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): + # Checks if after instantiation, grain_inner_radius and grain_height are valid functions + 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 + + assert times[0] == 0 # expected initial time + assert values[0] == motor.grain_initial_inner_radius # expected initial inner radius + assert values[-1] <= motor.grain_outer_radius # final inner radius should be less or equal than outer radius + + # Evaluates without error + val = motor.grain_inner_radius(0.5) # evaluate at intermediate time + assert isinstance(val, float) \ No newline at end of file