Skip to content

Commit b86bf8f

Browse files
ENH: Add custom exceptions and unstable rocket warning (#285) (#970)
* 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 * test: add coverage for Rocket input validation exceptions * MNT: fix lint formatting and skip UnstableRocketWarning for GenericSurface black/ruff reformat the multi-name exception imports in rocketpy/__init__.py and rocketpy/rocket/rocket.py. evaluate_center_of_pressure ignores GenericSurface lift coefficients, so the computed static margin does not reflect rockets that rely on them. Skip the UnstableRocketWarning in that case to avoid false positives, echoing the concern raised in the review of #764 that blocked a similar check. * DOC: add changelog entry for PR #970 --------- Co-authored-by: Gui-FernandesBR <guilherme_fernandes@usp.br>
1 parent 7288682 commit b86bf8f

5 files changed

Lines changed: 160 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Attention: The newest changes should be on top -->
3232

3333
### Added
3434

35+
- ENH: Add custom exceptions and unstable rocket warning [#970](https://github.com/RocketPy-Team/RocketPy/pull/970)
3536
- ENH: MNT: Remove redundant wind_heading/wind_direction functions from EnvironmentAnalysis [#1041](https://github.com/RocketPy-Team/RocketPy/pull/1041)
3637
- ENH: Pass acceleration (`u_dot`) data to parachute trigger functions [#911](https://github.com/RocketPy-Team/RocketPy/pull/911)
3738
- ENH: Add 3D flight trajectory and attitude animations in Flight plots layer [#909](https://github.com/RocketPy-Team/RocketPy/pull/909)

rocketpy/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
from .control import _Controller
22
from .environment import Environment, EnvironmentAnalysis
3+
from .exceptions import (
4+
InvalidInertiaError,
5+
InvalidParameterError,
6+
UnstableRocketWarning,
7+
)
38
from .mathutils import (
49
Function,
510
PiecewiseFunction,

rocketpy/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Custom exceptions and warnings for RocketPy."""
2+
3+
4+
class RocketPyError(Exception):
5+
"""Base class for all RocketPy exceptions."""
6+
7+
8+
class InvalidParameterError(RocketPyError, ValueError):
9+
"""Raised when a constructor parameter has an invalid value (e.g. negative
10+
radius or mass)."""
11+
12+
13+
class InvalidInertiaError(RocketPyError, ValueError):
14+
"""Raised when the inertia tuple/list does not have the expected length."""
15+
16+
17+
class UnstableRocketWarning(UserWarning):
18+
"""Issued when the rocket's static margin is negative at motor ignition,
19+
indicating an aerodynamically unstable configuration.
20+
21+
Not issued when the rocket has any ``GenericSurface`` aerodynamic
22+
surfaces, since their lift coefficient derivative is not accounted for
23+
in the center of pressure calculation, making the static margin
24+
unreliable for this check in that case.
25+
"""

rocketpy/rocket/rocket.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins
2727
from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin
2828
from rocketpy.rocket.aero_surface.generic_surface import GenericSurface
29+
from rocketpy.exceptions import (
30+
InvalidInertiaError,
31+
InvalidParameterError,
32+
UnstableRocketWarning,
33+
)
2934
from rocketpy.rocket.components import Components
3035
from rocketpy.rocket.parachute import Parachute
3136
from rocketpy.tools import (
@@ -311,6 +316,22 @@ def __init__( # pylint: disable=too-many-statements
311316
+ '"tail_to_nose" and "nose_to_tail".'
312317
)
313318

319+
# Validate inputs
320+
if not isinstance(radius, (int, float)) or radius <= 0:
321+
raise InvalidParameterError(
322+
f"Rocket radius must be a positive number, got {radius!r}."
323+
)
324+
if not isinstance(mass, (int, float)) or mass <= 0:
325+
raise InvalidParameterError(
326+
f"Rocket mass must be a positive number, got {mass!r}."
327+
)
328+
if not isinstance(inertia, (tuple, list)) or len(inertia) not in (3, 6):
329+
raise InvalidInertiaError(
330+
"Inertia must be a tuple or list with 3 components (I_11, I_22, I_33) "
331+
"or 6 components (I_11, I_22, I_33, I_12, I_13, I_23), "
332+
f"got length {len(inertia) if isinstance(inertia, (tuple, list)) else 'N/A'}."
333+
)
334+
314335
# Define rocket inertia attributes in SI units
315336
self.mass = mass
316337
inertia = (*inertia, 0, 0, 0) if len(inertia) == 3 else inertia
@@ -734,6 +755,26 @@ def evaluate_static_margin(self):
734755
self.static_margin.set_discrete(
735756
lower=0, upper=self.motor.burn_out_time, samples=200
736757
)
758+
# Warn the user if the rocket is aerodynamically unstable at ignition.
759+
# Skipped when GenericSurface instances are present: their lift
760+
# coefficient derivative is not accounted for in
761+
# evaluate_center_of_pressure, so the computed static margin does not
762+
# reflect their contribution and cannot be trusted for this check.
763+
has_generic_surface = any(
764+
isinstance(aero_surface, GenericSurface)
765+
for aero_surface, _position in self.aerodynamic_surfaces
766+
)
767+
if not has_generic_surface:
768+
initial_static_margin = self.static_margin.get_value_opt(0)
769+
if initial_static_margin < 0:
770+
warnings.warn(
771+
f"The rocket has a negative static margin ({initial_static_margin:.2f} cal) "
772+
"at motor ignition (t=0), indicating an aerodynamically unstable "
773+
"configuration. Check the placement of fins and nose cone relative "
774+
"to the center of mass.",
775+
UnstableRocketWarning,
776+
stacklevel=2,
777+
)
737778
return self.static_margin
738779

739780
def evaluate_dry_inertias(self):

tests/unit/rocket/test_rocket.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
import numpy as np
66
import pytest
77

8-
from rocketpy import Function, NoseCone, Rocket, SolidMotor
8+
from rocketpy import Function, GenericSurface, NoseCone, Rocket, SolidMotor
9+
from rocketpy.exceptions import (
10+
InvalidInertiaError,
11+
InvalidParameterError,
12+
UnstableRocketWarning,
13+
)
914
from rocketpy.mathutils.vector_matrix import Vector
1015
from rocketpy.motors.empty_motor import EmptyMotor
1116
from rocketpy.motors.motor import Motor
@@ -835,3 +840,85 @@ def test_drag_input_types_supported_for_power_on_and_power_off(tmp_path):
835840

836841
assert rocket.power_off_drag_7d(*query_point) == pytest.approx(expected)
837842
assert rocket.power_on_drag_7d(*query_point) == pytest.approx(expected)
843+
844+
845+
@pytest.mark.parametrize("radius", [-1, 0, -0.001])
846+
def test_rocket_invalid_radius_raises(radius):
847+
"""InvalidParameterError must be raised for non-positive radius values."""
848+
with pytest.raises(InvalidParameterError, match="radius"):
849+
Rocket(
850+
radius=radius,
851+
mass=10,
852+
inertia=(0.1, 0.1, 0.01),
853+
power_off_drag=0.3,
854+
power_on_drag=0.3,
855+
center_of_mass_without_motor=0,
856+
)
857+
858+
859+
@pytest.mark.parametrize("mass", [-1, 0, -0.001])
860+
def test_rocket_invalid_mass_raises(mass):
861+
"""InvalidParameterError must be raised for non-positive mass values."""
862+
with pytest.raises(InvalidParameterError, match="mass"):
863+
Rocket(
864+
radius=0.05,
865+
mass=mass,
866+
inertia=(0.1, 0.1, 0.01),
867+
power_off_drag=0.3,
868+
power_on_drag=0.3,
869+
center_of_mass_without_motor=0,
870+
)
871+
872+
873+
@pytest.mark.parametrize("inertia", [(0.1,), (0.1, 0.1), (0.1, 0.1, 0.01, 0.0, 0.0)])
874+
def test_rocket_invalid_inertia_length_raises(inertia):
875+
"""InvalidInertiaError must be raised when inertia tuple has wrong length."""
876+
with pytest.raises(InvalidInertiaError):
877+
Rocket(
878+
radius=0.05,
879+
mass=10,
880+
inertia=inertia,
881+
power_off_drag=0.3,
882+
power_on_drag=0.3,
883+
center_of_mass_without_motor=0,
884+
)
885+
886+
887+
def test_unstable_rocket_warning_raised(calisto):
888+
"""UnstableRocketWarning must be raised when the static margin at motor
889+
ignition is negative."""
890+
nose = NoseCone(
891+
length=0.55829,
892+
kind="vonkarman",
893+
base_radius=0.0635,
894+
rocket_radius=0.0635,
895+
name="Nose Cone",
896+
)
897+
with pytest.warns(UnstableRocketWarning):
898+
calisto.add_surfaces(nose, 1.16)
899+
assert calisto.static_margin(0) < 0
900+
901+
902+
def test_unstable_rocket_warning_skipped_with_generic_surface(calisto):
903+
"""UnstableRocketWarning must not be raised when the rocket has a
904+
GenericSurface, since its lift coefficient derivative is not accounted
905+
for in the center of pressure calculation, making the static margin
906+
unreliable for this check."""
907+
nose = NoseCone(
908+
length=0.55829,
909+
kind="vonkarman",
910+
base_radius=0.0635,
911+
rocket_radius=0.0635,
912+
name="Nose Cone",
913+
)
914+
generic_surface = GenericSurface(
915+
reference_area=None,
916+
reference_length=None,
917+
coefficients={
918+
"cL": lambda alpha, beta, mach, reynolds, pitch_rate, yaw_rate, roll_rate: 1
919+
},
920+
)
921+
with warnings.catch_warnings():
922+
warnings.simplefilter("error", UnstableRocketWarning)
923+
calisto.add_surfaces([nose, generic_surface], [1.16, 0])
924+
assert calisto.static_margin(0) < 0

0 commit comments

Comments
 (0)