diff --git a/CHANGELOG.md b/CHANGELOG.md index 2183005f8..4ec3083c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,9 +50,11 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: Wind Heading Profile Plots are not that good [#253](https://github.com/RocketPy-Team/RocketPy/issues/253) - BUG: fix NaN in ND linear interpolation outside convex hull [#926](https://github.com/RocketPy-Team/RocketPy/issues/926) - BUG: Add wraparound logic for wind direction in environment plots [#939](https://github.com/RocketPy-Team/RocketPy/pull/939) + ## [v1.12.1] - 2026-04-03 ### Fixed diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index 129317bea..3d60ebaba 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/environment/environment.py b/rocketpy/environment/environment.py index 1e9d8bb5a..4dcb83c5a 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -538,6 +538,26 @@ def __set_wind_speed_function(self, source): ) def __set_wind_direction_function(self, source): + if isinstance(source, (np.ndarray, list, tuple)): + source_array = np.asarray(source) + if source_array.ndim == 2: + heights = source_array[:, 0] + directions = np.deg2rad(source_array[:, 1]) + unwrapped_directions = np.unwrap(directions) + unwrapped_directions_deg = np.rad2deg(unwrapped_directions) + source = np.column_stack((heights, unwrapped_directions_deg)) + self.wind_direction_unwrapped = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Direction (Deg True)", + interpolation="linear", + ) + self.wind_direction = Function( + lambda h: self.wind_direction_unwrapped(h) % 360, + inputs="Height Above Sea Level (m)", + outputs="Wind Direction (Deg True)", + ) + return self.wind_direction = Function( source, inputs="Height Above Sea Level (m)", @@ -546,6 +566,26 @@ def __set_wind_direction_function(self, source): ) def __set_wind_heading_function(self, source): + if isinstance(source, (np.ndarray, list, tuple)): + source_array = np.asarray(source) + if source_array.ndim == 2: + heights = source_array[:, 0] + headings = np.deg2rad(source_array[:, 1]) + unwrapped_headings = np.unwrap(headings) + unwrapped_headings_deg = np.rad2deg(unwrapped_headings) + source = np.column_stack((heights, unwrapped_headings_deg)) + self.wind_heading_unwrapped = Function( + source, + inputs="Height Above Sea Level (m)", + outputs="Wind Heading (Deg True)", + interpolation="linear", + ) + self.wind_heading = Function( + lambda h: self.wind_heading_unwrapped(h) % 360, + inputs="Height Above Sea Level (m)", + outputs="Wind Heading (Deg True)", + ) + return self.wind_heading = Function( source, inputs="Height Above Sea Level (m)", 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 84655b6d7..c707dbce6 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -26,6 +26,7 @@ from rocketpy.rocket.aero_surface.fins.free_form_fins import FreeFormFins from rocketpy.rocket.aero_surface.fins.trapezoidal_fin import TrapezoidalFin 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 ( @@ -311,6 +312,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 @@ -734,6 +751,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): diff --git a/tests/unit/environment/test_environment.py b/tests/unit/environment/test_environment.py index 222eb9a2d..08d87f55d 100644 --- a/tests/unit/environment/test_environment.py +++ b/tests/unit/environment/test_environment.py @@ -604,6 +604,34 @@ def test_set_atmospheric_model_raises_for_unknown_model_type(example_plain_env): environment.set_atmospheric_model(type="unknown_type") +def test_wind_heading_direction_wraparound_interpolation(example_plain_env): + """Test that wind heading and direction interpolation wraps around correctly + across the 360°/0° boundary when initialized with a 2D array. + """ + # Create discrete points at 1000m and 1100m + # 350 deg at 1000m, 10 deg at 1100m. + # Midpoint should be 360 deg or 0 deg, NOT 180 deg. + heading_data = np.array([[1000, 350], [1100, 10]]) + direction_data = np.array([[1000, 350], [1100, 10]]) + + example_plain_env._Environment__set_wind_heading_function(heading_data) + example_plain_env._Environment__set_wind_direction_function(direction_data) + + # Evaluate at midpoint (1050m) + mid_heading = example_plain_env.wind_heading(1050) + mid_direction = example_plain_env.wind_direction(1050) + + # Check that it's close to 0 or 360 (which is also 0 modulo 360) + assert np.isclose(mid_heading, 0.0) or np.isclose(mid_heading, 360.0) + assert np.isclose(mid_direction, 0.0) or np.isclose(mid_direction, 360.0) + + # Also test another wrap-around case, e.g. 10 to 350 + heading_data2 = np.array([[1000, 10], [1100, 350]]) + example_plain_env._Environment__set_wind_heading_function(heading_data2) + mid_heading2 = example_plain_env.wind_heading(1050) + assert np.isclose(mid_heading2, 0.0) or np.isclose(mid_heading2, 360.0) + + @pytest.mark.parametrize("shortcut_name", ["AIGFS", "HRRR"]) def test_forecast_shortcut_and_dictionary_are_case_insensitive( monkeypatch, shortcut_name diff --git a/tests/unit/rocket/test_rocket.py b/tests/unit/rocket/test_rocket.py index 3c7725fa5..256ddd49b 100644 --- a/tests/unit/rocket/test_rocket.py +++ b/tests/unit/rocket/test_rocket.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from rocketpy import Function, NoseCone, Rocket, SolidMotor +from rocketpy import Function, NoseCone, Rocket, SolidMotor, InvalidParameterError, InvalidInertiaError, UnstableRocketWarning from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors.empty_motor import EmptyMotor from rocketpy.motors.motor import Motor @@ -835,3 +835,52 @@ 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) + + +def test_rocket_constructor_invalid_parameters(): + """Verify Rocket constructor raises correct exceptions for invalid inputs.""" + # Test invalid radius + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius=0, mass=10, inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius=-0.1, mass=10, inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius="invalid", mass=10, inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + + # Test invalid mass + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius=0.05, mass=0, inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius=0.05, mass=-5, inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidParameterError, match="must be a positive number"): + Rocket(radius=0.05, mass="invalid", inertia=(1, 1, 1), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + + # Test invalid inertia + with pytest.raises(InvalidInertiaError, match="must be a tuple or list with 3 components"): + Rocket(radius=0.05, mass=10, inertia=(1, 2), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidInertiaError, match="must be a tuple or list with 3 components"): + Rocket(radius=0.05, mass=10, inertia=(1, 2, 3, 4), power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + with pytest.raises(InvalidInertiaError, match="must be a tuple or list with 3 components"): + Rocket(radius=0.05, mass=10, inertia="invalid", power_off_drag=0, power_on_drag=0, center_of_mass_without_motor=0) + + +def test_unstable_rocket_warning(calisto_motorless, calisto_nose_cone): + """Verify UnstableRocketWarning is issued when the rocket is unstable at t=0, + and not issued when it is stable.""" + # 1. Unstable case: adding nose cone at position 1.160 makes it unstable + with pytest.warns(UnstableRocketWarning, match="negative static margin"): + calisto_motorless.add_surfaces(calisto_nose_cone, 1.160) + + # 2. Stable case: construct a stable rocket + stable_rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=0.5, + power_on_drag=0.5, + center_of_mass_without_motor=2.0, + ) + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always") + stable_rocket.add_surfaces(calisto_nose_cone, 1.160) + assert not any(issubclass(w.category, UnstableRocketWarning) for w in record)