From ac18f3227665ed5072efb32f77bcec4e0676af87 Mon Sep 17 00:00:00 2001 From: Matan Date: Tue, 16 Jun 2026 20:47:17 +0300 Subject: [PATCH 1/2] ENH: Add custom exceptions and unstable rocket warning (#285) Introduces a dedicated `rocketpy/exceptions.py` module with three new types: `InvalidParameterError` (bad radius/mass values), `InvalidInertiaError` (wrong inertia tuple length), and `UnstableRocketWarning` (negative static margin at ignition). Validation is applied in `Rocket.__init__` and a warning is issued automatically after `evaluate_static_margin` when the rocket is aerodynamically unstable. All three new names are exported from the top-level `rocketpy` package. Closes #285 --- rocketpy/__init__.py | 1 + rocketpy/exceptions.py | 19 +++++++++++++++++++ rocketpy/rocket/rocket.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 rocketpy/exceptions.py diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index 852a16aef..feb7dc7d3 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -1,5 +1,6 @@ from .control import _Controller from .environment import Environment, EnvironmentAnalysis +from .exceptions import InvalidInertiaError, InvalidParameterError, UnstableRocketWarning from .mathutils import ( Function, PiecewiseFunction, diff --git a/rocketpy/exceptions.py b/rocketpy/exceptions.py new file mode 100644 index 000000000..e56982874 --- /dev/null +++ b/rocketpy/exceptions.py @@ -0,0 +1,19 @@ +"""Custom exceptions and warnings for RocketPy.""" + + +class RocketPyError(Exception): + """Base class for all RocketPy exceptions.""" + + +class InvalidParameterError(RocketPyError, ValueError): + """Raised when a constructor parameter has an invalid value (e.g. negative + radius or mass).""" + + +class InvalidInertiaError(RocketPyError, ValueError): + """Raised when the inertia tuple/list does not have the expected length.""" + + +class UnstableRocketWarning(UserWarning): + """Issued when the rocket's static margin is negative at motor ignition, + indicating an aerodynamically unstable configuration.""" diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index e3692d2e8..4bc8925d7 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -23,6 +23,7 @@ ) from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.generic_surface import GenericSurface +from rocketpy.exceptions import InvalidInertiaError, InvalidParameterError, UnstableRocketWarning from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute from rocketpy.tools import ( @@ -308,6 +309,22 @@ def __init__( # pylint: disable=too-many-statements + '"tail_to_nose" and "nose_to_tail".' ) + # Validate inputs + if not isinstance(radius, (int, float)) or radius <= 0: + raise InvalidParameterError( + f"Rocket radius must be a positive number, got {radius!r}." + ) + if not isinstance(mass, (int, float)) or mass <= 0: + raise InvalidParameterError( + f"Rocket mass must be a positive number, got {mass!r}." + ) + if not isinstance(inertia, (tuple, list)) or len(inertia) not in (3, 6): + raise InvalidInertiaError( + "Inertia must be a tuple or list with 3 components (I_11, I_22, I_33) " + "or 6 components (I_11, I_22, I_33, I_12, I_13, I_23), " + f"got length {len(inertia) if isinstance(inertia, (tuple, list)) else 'N/A'}." + ) + # Define rocket inertia attributes in SI units self.mass = mass inertia = (*inertia, 0, 0, 0) if len(inertia) == 3 else inertia @@ -725,6 +742,17 @@ def evaluate_static_margin(self): self.static_margin.set_discrete( lower=0, upper=self.motor.burn_out_time, samples=200 ) + # Warn the user if the rocket is aerodynamically unstable at ignition + initial_static_margin = self.static_margin.get_value_opt(0) + if initial_static_margin < 0: + warnings.warn( + f"The rocket has a negative static margin ({initial_static_margin:.2f} cal) " + "at motor ignition (t=0), indicating an aerodynamically unstable " + "configuration. Check the placement of fins and nose cone relative " + "to the center of mass.", + UnstableRocketWarning, + stacklevel=2, + ) return self.static_margin def evaluate_dry_inertias(self): From a03e17d1c3c5dc269c1388be5084e3862f525459 Mon Sep 17 00:00:00 2001 From: Matan Date: Sun, 21 Jun 2026 00:20:16 +0300 Subject: [PATCH 2/2] test: add coverage for Rocket input validation exceptions --- tests/unit/rocket/test_rocket.py | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 3c7725fa5..14a752897 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -6,6 +6,7 @@ import pytest from rocketpy import Function, NoseCone, Rocket, SolidMotor +from rocketpy.exceptions import InvalidInertiaError, InvalidParameterError from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors.empty_motor import EmptyMotor from rocketpy.motors.motor import Motor @@ -835,3 +836,45 @@ def test_drag_input_types_supported_for_power_on_and_power_off(tmp_path): assert rocket.power_off_drag_7d(*query_point) == pytest.approx(expected) assert rocket.power_on_drag_7d(*query_point) == pytest.approx(expected) + + +@pytest.mark.parametrize("radius", [-1, 0, -0.001]) +def test_rocket_invalid_radius_raises(radius): + """InvalidParameterError must be raised for non-positive radius values.""" + with pytest.raises(InvalidParameterError, match="radius"): + Rocket( + radius=radius, + mass=10, + inertia=(0.1, 0.1, 0.01), + power_off_drag=0.3, + power_on_drag=0.3, + center_of_mass_without_motor=0, + ) + + +@pytest.mark.parametrize("mass", [-1, 0, -0.001]) +def test_rocket_invalid_mass_raises(mass): + """InvalidParameterError must be raised for non-positive mass values.""" + with pytest.raises(InvalidParameterError, match="mass"): + Rocket( + radius=0.05, + mass=mass, + inertia=(0.1, 0.1, 0.01), + power_off_drag=0.3, + power_on_drag=0.3, + center_of_mass_without_motor=0, + ) + + +@pytest.mark.parametrize("inertia", [(0.1,), (0.1, 0.1), (0.1, 0.1, 0.01, 0.0, 0.0)]) +def test_rocket_invalid_inertia_length_raises(inertia): + """InvalidInertiaError must be raised when inertia tuple has wrong length.""" + with pytest.raises(InvalidInertiaError): + Rocket( + radius=0.05, + mass=10, + inertia=inertia, + power_off_drag=0.3, + power_on_drag=0.3, + center_of_mass_without_motor=0, + )