diff --git a/CHANGELOG.md b/CHANGELOG.md index b2de3b092..00194aef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,15 @@ Attention: The newest changes should be on top --> ### Added +### Changed + +### Fixed + + +## [v1.13.0] - 2026-07-04 + +### Added + - ENH: ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055) - ENH: TST: Add unit tests for evaluate_reduced_mass and remove TODO [#1051](https://github.com/RocketPy-Team/RocketPy/pull/1051) - ENH: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) @@ -60,6 +69,11 @@ Attention: The newest changes should be on top --> ### Changed +- REL: bumps up rocketpy version to 1.13.0 [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) +- MNT: **Breaking**: `Parachute` is now an abstract base class and can no longer be instantiated directly; use `HemisphericalParachute` (or `Rocket.add_parachute`, which builds it for you). Loading old serialized files still works [#958](https://github.com/RocketPy-Team/RocketPy/pull/958) +- MNT: Discrete controllers are now called exactly once per time node (previously twice); results may change for stateful controllers and `observed_variables` no longer contains duplicated entries [#949](https://github.com/RocketPy-Team/RocketPy/pull/949) +- MNT: Multi-dimensional linear `Function` objects now apply their extrapolation rule to points outside the data's convex hull (previously returned NaN) [#969](https://github.com/RocketPy-Team/RocketPy/pull/969) +- MNT: Informational messages previously shown with `print()` now use Python logging and are silent by default; call `rocketpy.utils.enable_logging()` to see them [#973](https://github.com/RocketPy-Team/RocketPy/pull/973) - ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055) ### Deprecated @@ -72,6 +86,8 @@ Attention: The newest changes should be on top --> ### Fixed +- BUG: fix individual fin (`TrapezoidalFin`, `EllipticalFin`, `FreeFormFin`) serialization crash when saving rockets/flights [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) +- BUG: support the new Wyoming sounding WSGI page format (legacy cgi-bin endpoint was discontinued by UWyo) [#1048](https://github.com/RocketPy-Team/RocketPy/pull/1048) - FIX: pre-release hardening — bug fixes across the unreleased features [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) - BUG: Remove duplicate controller process; controllers were being invoked twice per time node [#949](https://github.com/RocketPy-Team/RocketPy/pull/949) - BUG: fix wind heading and direction wraparound interpolation [#974](https://github.com/RocketPy-Team/RocketPy/pull/974) diff --git a/README.md b/README.md index a29c765be..8a3a4b651 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ Here is just a quick taste of what RocketPy is able to calculate. There are hund ![6-DOF Trajectory Plot](https://raw.githubusercontent.com/RocketPy-Team/RocketPy/master/docs/static/rocketpy_example_trajectory.svg) -If you want to see the trajectory on Google Earth, RocketPy acn easily export a KML file for you: +If you want to see the trajectory on Google Earth, RocketPy can easily export a KML file for you: ```python from rocketpy.simulation import FlightDataExporter diff --git a/docs/conf.py b/docs/conf.py index b81f98f75..4779e5d5f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ author = "RocketPy Team" # The full version, including alpha/beta/rc tags -release = "1.12.1" +release = "1.13.0" # -- General configuration --------------------------------------------------- diff --git a/docs/development/testing.rst b/docs/development/testing.rst index fd521167b..e84fde2a2 100644 --- a/docs/development/testing.rst +++ b/docs/development/testing.rst @@ -257,8 +257,7 @@ Consider the following integration test: """ # TODO:: this should be added to the set_atmospheric_model() method as a # "file" option, instead of receiving the URL as a string. - URL = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" - # give it at least 5 times to try to download the file + URL = "https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05+00:00:00&id=83779&type=TEXT:LIST" example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=URL) assert example_plain_env.all_info() is None @@ -267,7 +266,7 @@ Consider the following integration test: abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0) < 1e-8 ) - assert abs(example_plain_env.wind_velocity_x(0) - -2.9005178894925043) < 1e-8 + assert abs(example_plain_env.wind_velocity_x(0) - -2.9130471244363165) < 1e-8 assert abs(example_plain_env.temperature(100) - 291.75) < 1e-8 This test contains two fundamental traits which defines it as an integration test: diff --git a/docs/reference/classes/utils/logging.rst b/docs/reference/classes/utils/logging.rst index 4d7ce49dc..c2e933a76 100644 --- a/docs/reference/classes/utils/logging.rst +++ b/docs/reference/classes/utils/logging.rst @@ -1,5 +1,4 @@ Logging functions ----------------- -.. automodule:: rocketpy.utils - :members: +.. autofunction:: rocketpy.utilities.enable_logging diff --git a/docs/reference/classes/utils/utilities.rst b/docs/reference/classes/utils/utilities.rst index 6376c2aad..b4bde76ca 100644 --- a/docs/reference/classes/utils/utilities.rst +++ b/docs/reference/classes/utils/utilities.rst @@ -11,4 +11,5 @@ At RocketPy projects, utilities are functions that: Below you have a list of all utilities functions available in rocketpy. .. automodule:: rocketpy.utilities - :members: \ No newline at end of file + :members: + :exclude-members: enable_logging \ No newline at end of file diff --git a/docs/user/airbrakes.rst b/docs/user/airbrakes.rst index d45251838..5d47d4d6b 100644 --- a/docs/user/airbrakes.rst +++ b/docs/user/airbrakes.rst @@ -106,52 +106,12 @@ Defining the Controller Function Lets start by defining a very simple controller function. -The ``controller_function`` must take in the following arguments, in this -order: - -1. ``time`` (float): The current simulation time in seconds. -2. ``sampling_rate`` (float or ``None``): The rate at which the controller - function is called, measured in Hertz (Hz). It is ``None`` for continuous - controllers, so guard any ``1 / sampling_rate`` computation against ``None``. -3. ``state`` (list): The state vector of the simulation. The state - is a list containing the following values, in this order: - - - ``x``: The x position of the rocket, in meters. - - ``y``: The y position of the rocket, in meters. - - ``z``: The z position of the rocket, in meters. - - ``v_x``: The x component of the velocity of the rocket, in meters per - second. - - ``v_y``: The y component of the velocity of the rocket, in meters per - second. - - ``v_z``: The z component of the velocity of the rocket, in meters per - second. - - ``e0``: The first component of the quaternion representing the rotation - of the rocket. - - ``e1``: The second component of the quaternion representing the rotation - of the rocket. - - ``e2``: The third component of the quaternion representing the rotation - of the rocket. - - ``e3``: The fourth component of the quaternion representing the rotation - of the rocket. - - ``w_x``: The x component of the angular velocity of the rocket, in - radians per second. - - ``w_y``: The y component of the angular velocity of the rocket, in - radians per second. - - ``w_z``: The z component of the angular velocity of the rocket, in - radians per second. - -4. ``state_history`` (list): A record of the rocket's state at each - step throughout the simulation. The state_history is organized as - a list of lists, with each sublist containing a state vector. The - last item in the list always corresponds to the previous state - vector, providing a chronological sequence of the rocket's - evolving states. -5. ``observed_variables`` (list): A list containing the variables that - the controller function returns. The return of each controller - function call is appended to the observed_variables list. The - initial value in the first step of the simulation of this list is - provided by the ``initial_observed_variables`` argument. -6. ``air_brakes`` (AirBrakes): The ``AirBrakes`` instance being controlled. +The ``controller_function`` receives information about the simulation at the +current time step and sets the air brakes' deployment level. See +:ref:`controllers` for the full description of its arguments and call signature. +For air brakes, the controlled object (the ``air_brakes`` argument) is the +:class:`rocketpy.AirBrakes` instance, whose ``deployment_level`` the function +sets. Our example ``controller_function`` will deploy the air brakes when the rocket reaches 1500 meters above the ground. The deployment level will be function of the @@ -227,22 +187,6 @@ Lets define the controller function: .. note:: - - The ``controller_function`` accepts 6, 7, or 8 parameters for backward - compatibility: - - * **6 parameters** (original): ``time``, ``sampling_rate``, ``state``, - ``state_history``, ``observed_variables``, ``air_brakes`` - * **7 parameters** (with sensors): adds ``sensors`` as the 7th parameter - * **8 parameters** (with environment): adds ``sensors`` and ``environment`` - as the 7th and 8th parameters - - - The **environment parameter** provides access to atmospheric conditions - (wind, temperature, pressure, elevation) without relying on global variables. - This enables proper serialization of rockets with air brakes and improves - code modularity. Available methods include ``environment.elevation``, - ``environment.wind_velocity_x(altitude)``, ``environment.wind_velocity_y(altitude)``, - ``environment.speed_of_sound(altitude)``, and others. - - The code inside the ``controller_function`` can be as complex as needed. Anything can be implemented inside the function, including filters, apogee prediction, and any controller logic. @@ -252,15 +196,10 @@ Lets define the controller function: 0 or higher than 1. If you want to disable this feature, set ``clamp`` to ``False`` when defining the air brakes. - - Anything can be returned by the ``controller_function``. The returned - values will be saved in the ``observed_variables`` list at every time step - and can then be accessed by the ``controller_function`` at the next time - step. The saved values can also be accessed after the simulation is - finished. This is useful for debugging and for plotting the results. - - - The ``controller_function`` can also be defined in a separate file and - imported into the simulation script. This includes importing a ``c`` or - ``cpp`` code into Python. + - See :ref:`controllers` for the controller-function signature (including the + 6/7/8-parameter forms and the ``environment`` argument), how returned + values are stored in ``observed_variables``, and discrete vs. continuous + controllers. Defining the Drag Coefficient @@ -375,40 +314,14 @@ controller function. If you want to disable this feature, set ``clamp`` to For more information on the :class:`rocketpy.AirBrakes` class initialization, see :class:`rocketpy.AirBrakes.__init__` section. -.. _discrete-vs-continuous-controllers: - -Discrete vs. Continuous Controllers -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The ``sampling_rate`` argument determines *when* the controller function is -called during the flight simulation: - -- **Discrete controller** (``sampling_rate`` set to a number, e.g. ``10``): - the controller function is called at fixed intervals of ``1 / sampling_rate`` - seconds. This mirrors a real flight computer that reads its sensors and - updates its actuators at a fixed frequency, and is the recommended choice - when you want the simulation to reflect the actual control-loop rate of your - hardware. -- **Continuous controller** (``sampling_rate=None``): the controller function - is called at *every* solver step of the numerical integrator. Use this when - you want the control law to act as a continuous function of the state rather - than a sampled one (for example, when validating a control model - analytically). - -.. warning:: - - For continuous controllers, ``sampling_rate`` is passed to your controller - function as ``None``. Any computation such as ``1 / sampling_rate`` (a - common pattern for rate-limiting deployment, as shown above) must guard - against ``None`` to avoid a ``TypeError``. - .. note:: - Discrete controllers add their sampling instants as time nodes to the - simulation, so remember to set ``time_overshoot=False`` in the ``Flight`` - (see below) to make the integrator stop exactly at those instants. - Continuous controllers do not add time nodes; they are evaluated on the - integrator's own steps. + Because our controller uses ``sampling_rate=10``, it is a **discrete** + controller and adds its sampling instants as time nodes to the simulation. + Remember to set ``time_overshoot=False`` in the ``Flight`` (see below) so the + integrator stops exactly at those instants. See + :ref:`discrete-vs-continuous-controllers` for the difference between discrete + and continuous controllers. Simulating a Flight ------------------- diff --git a/docs/user/controllers.rst b/docs/user/controllers.rst new file mode 100644 index 000000000..0b866dfb0 --- /dev/null +++ b/docs/user/controllers.rst @@ -0,0 +1,132 @@ +.. _controllers: + +Controllers +=========== + +RocketPy can simulate active, in-flight control systems — such as air brakes or +other actuators — through a *controller function* that you attach to a +controllable object (for example, an :class:`rocketpy.AirBrakes` added with +``Rocket.add_air_brakes``). During the flight simulation, RocketPy calls your +controller function at the appropriate times, letting it read the current +simulation state and command the actuator. + +This page describes the controller-function interface and how its call timing is +governed. For a complete, runnable example that puts these pieces together, see +the :doc:`Air Brakes Example `. + +The Controller Function +----------------------- + +A controller function receives information about the simulation up to the +current time step and sets the state of the controlled object. It must take in +the following arguments, in this order: + +1. ``time`` (float): The current simulation time in seconds. +2. ``sampling_rate`` (float or ``None``): The rate at which the controller + function is called, measured in Hertz (Hz). It is ``None`` for continuous + controllers, so guard any ``1 / sampling_rate`` computation against ``None``. +3. ``state`` (list): The state vector of the simulation. The state + is a list containing the following values, in this order: + + - ``x``: The x position of the rocket, in meters. + - ``y``: The y position of the rocket, in meters. + - ``z``: The z position of the rocket, in meters. + - ``v_x``: The x component of the velocity of the rocket, in meters per + second. + - ``v_y``: The y component of the velocity of the rocket, in meters per + second. + - ``v_z``: The z component of the velocity of the rocket, in meters per + second. + - ``e0``: The first component of the quaternion representing the rotation + of the rocket. + - ``e1``: The second component of the quaternion representing the rotation + of the rocket. + - ``e2``: The third component of the quaternion representing the rotation + of the rocket. + - ``e3``: The fourth component of the quaternion representing the rotation + of the rocket. + - ``w_x``: The x component of the angular velocity of the rocket, in + radians per second. + - ``w_y``: The y component of the angular velocity of the rocket, in + radians per second. + - ``w_z``: The z component of the angular velocity of the rocket, in + radians per second. + +4. ``state_history`` (list): A record of the rocket's state at each + step throughout the simulation. The state_history is organized as + a list of lists, with each sublist containing a state vector. The + last item in the list always corresponds to the previous state + vector, providing a chronological sequence of the rocket's + evolving states. +5. ``observed_variables`` (list): A list containing the variables that + the controller function returns. The return of each controller + function call is appended to the observed_variables list. The + initial value in the first step of the simulation of this list is + provided by the ``initial_observed_variables`` argument. +6. The **controlled object** (e.g. ``air_brakes``): the instance being + controlled, whose attributes the function sets (for example, + ``air_brakes.deployment_level``). + +.. note:: + + - The controller function accepts 6, 7, or 8 parameters for backward + compatibility: + + * **6 parameters** (original): ``time``, ``sampling_rate``, ``state``, + ``state_history``, ``observed_variables``, and the controlled object. + * **7 parameters** (with sensors): adds ``sensors`` as the 7th parameter. + * **8 parameters** (with environment): adds ``sensors`` and ``environment`` + as the 7th and 8th parameters. + + - The **environment parameter** provides access to atmospheric conditions + (wind, temperature, pressure, elevation) without relying on global + variables. This enables proper serialization of rockets with controllers + and improves code modularity. Available methods include + ``environment.elevation``, ``environment.wind_velocity_x(altitude)``, + ``environment.wind_velocity_y(altitude)``, + ``environment.speed_of_sound(altitude)``, and others. + + - Anything can be returned by the controller function. The returned values + are saved in the ``observed_variables`` list at every time step and can + then be accessed by the controller function at the next time step. The + saved values can also be accessed after the simulation is finished, which + is useful for debugging and for plotting the results. + + - The controller function can also be defined in a separate file and + imported into the simulation script. This includes importing ``c`` or + ``cpp`` code into Python. + +.. _discrete-vs-continuous-controllers: + +Discrete vs. Continuous Controllers +----------------------------------- + +The ``sampling_rate`` argument determines *when* the controller function is +called during the flight simulation: + +- **Discrete controller** (``sampling_rate`` set to a number, e.g. ``10``): + the controller function is called at fixed intervals of ``1 / sampling_rate`` + seconds. This mirrors a real flight computer that reads its sensors and + updates its actuators at a fixed frequency, and is the recommended choice + when you want the simulation to reflect the actual control-loop rate of your + hardware. +- **Continuous controller** (``sampling_rate=None``): the controller function + is called at *every* solver step of the numerical integrator. Use this when + you want the control law to act as a continuous function of the state rather + than a sampled one (for example, when validating a control model + analytically). + +.. warning:: + + For continuous controllers, ``sampling_rate`` is passed to your controller + function as ``None``. Any computation such as ``1 / sampling_rate`` (a + common pattern for rate-limiting deployment) must guard against ``None`` to + avoid a ``TypeError``. + +.. note:: + + Discrete controllers add their sampling instants as time nodes to the + simulation, so remember to set ``time_overshoot=False`` in the ``Flight`` + to make the integrator stop exactly at those instants. Continuous + controllers do not add time nodes; they are evaluated on the integrator's + own steps. diff --git a/docs/user/environment/1-atm-models/soundings.rst b/docs/user/environment/1-atm-models/soundings.rst index 191dbb8d3..39fa57d17 100644 --- a/docs/user/environment/1-atm-models/soundings.rst +++ b/docs/user/environment/1-atm-models/soundings.rst @@ -13,11 +13,18 @@ Wyoming Upper Air Soundings The University of Wyoming - College of Engineering - Department of Atmospheric Sciences has a comprehensive collection of atmospheric soundings on their website, -accessible `here `_. +accessible `here `_. For this example, we will use the sounding from 83779 SBMT Marte Civ Observations -at 04 Feb 2019, which can be accessed using this URL: -http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779 +at 05 Feb 2019, which can be accessed using this URL: +https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST + +.. important:: + + The University of Wyoming discontinued the legacy + ``weather.uwyo.edu/cgi-bin/sounding`` endpoint. URLs in that old format no + longer work; use the new ``weather.uwyo.edu/wsgi/sounding`` format shown + above, which RocketPy supports since v1.13.0. Initialize a new Environment instance: @@ -33,7 +40,7 @@ Initialize a new Environment instance: from rocketpy import Environment - url = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779" + url = "https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST" env = Environment() env.set_atmospheric_model(type="wyoming_sounding", file=url) diff --git a/docs/user/index.rst b/docs/user/index.rst index 9fcac7de0..6f9d9effa 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -27,6 +27,7 @@ RocketPy's User Guide Flight Comparator Class Parachute Triggers (Acceleration-Based) Deployable Payload + Controllers Air Brakes Example ../notebooks/sensors.ipynb ../matlab/matlab.rst diff --git a/docs/user/installation.rst b/docs/user/installation.rst index a08c65aeb..41ea2e3e4 100644 --- a/docs/user/installation.rst +++ b/docs/user/installation.rst @@ -19,7 +19,7 @@ If you want to choose a specific version to guarantee compatibility, you may ins .. code-block:: shell - pip install rocketpy==1.11.0 + pip install rocketpy==1.13.0 Optional Installation Method: ``conda`` diff --git a/docs/user/logging.rst b/docs/user/logging.rst index 13fd32e66..469175bde 100644 --- a/docs/user/logging.rst +++ b/docs/user/logging.rst @@ -18,14 +18,14 @@ Enabling logging ----------------- The easiest way to see RocketPy's internal log messages is the -:func:`rocketpy.utils.enable_logging` helper, which attaches a console +:func:`rocketpy.utilities.enable_logging` helper, which attaches a console handler to RocketPy's logger hierarchy: .. jupyter-execute:: import rocketpy - rocketpy.utils.enable_logging(level="INFO") + rocketpy.utilities.enable_logging(level="INFO") Once enabled, operations such as saving a file or completing a simulation will emit messages to the console, for example: @@ -51,7 +51,7 @@ shown: .. jupyter-execute:: # Show every internal runtime message, including solver ticks - rocketpy.utils.enable_logging(level="DEBUG") + rocketpy.utilities.enable_logging(level="DEBUG") Filtering by module -------------------- @@ -59,7 +59,7 @@ Filtering by module Because each RocketPy module exposes its own logger (e.g. ``rocketpy.simulation.flight``, ``rocketpy.environment.environment``), you can rely on the standard ``logging`` module to filter or redirect messages -from specific modules, without using :func:`rocketpy.utils.enable_logging` +from specific modules, without using :func:`rocketpy.utilities.enable_logging` at all: .. code-block:: python diff --git a/pyproject.toml b/pyproject.toml index 09ce86273..35862cb9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rocketpy" -version = "1.12.1" +version = "1.13.0" description="Advanced 6-DOF trajectory simulation for High-Power Rocketry." dynamic = ["dependencies"] readme = "README.md" diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index ce4576171..05c800094 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -1,4 +1,3 @@ -from . import utils from .control import _Controller from .environment import Environment, EnvironmentAnalysis from .exceptions import ( @@ -70,3 +69,8 @@ StochasticTail, StochasticTrapezoidalFins, ) + +# Imported last: utilities pulls in Environment/Rocket/encoders, which are only +# fully available once the imports above have run. Exposes +# ``rocketpy.utilities`` (including ``enable_logging``) on ``import rocketpy``. +from . import utilities diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index c4c1ed298..d63f50c61 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -1754,11 +1754,11 @@ def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements Example: - http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0200&TO=0200&STNM=82599 + https://weather.uwyo.edu/wsgi/sounding?datetime=2019-02-05%2012:00:00&id=83779&type=TEXT:LIST Notes ----- - More can be found at: http://weather.uwyo.edu/upperair/sounding.html. + More can be found at: https://weather.uwyo.edu/upperair/sounding.shtml. Returns ------- @@ -1770,7 +1770,9 @@ def process_wyoming_sounding(self, file): # pylint: disable=too-many-statements # Process Wyoming Sounding by finding data table and station info response_split_text = re.split("(<.{0,1}PRE>)", response.text) data_table = response_split_text[2] - station_info = response_split_text[6] + # Legacy CGI pages had extra
 blocks with station information;
+        # current WSGI pages have a single block with the data table only.
+        station_info = response_split_text[6] if len(response_split_text) > 6 else None
 
         # Transform data table into np array
         data_array = []
@@ -1792,8 +1794,10 @@ def process_wyoming_sounding(self, file):  # pylint: disable=too-many-statements
         self.__set_temperature_function(data_array[:, (1, 2)])
 
         # Retrieve wind-u and wind-v from data array
-        ## Converts Knots to m/s
-        data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6
+        ## Legacy pages report wind speed as SKNT (knots); current WSGI pages
+        ## report it as SPED (m/s) and need no conversion.
+        if "SKNT" in data_table.split("\n")[2]:
+            data_array[:, 7] = data_array[:, 7] * 1.852 / 3.6  # Knots to m/s
         ## Convert wind direction to wind heading
         data_array[:, 5] = (data_array[:, 6] + 180) % 360
         data_array[:, 3] = data_array[:, 7] * np.sin(data_array[:, 5] * np.pi / 180)
@@ -1811,13 +1815,16 @@ def process_wyoming_sounding(self, file):  # pylint: disable=too-many-statements
         self.__set_wind_direction_function(data_array[:, (1, 6)])
         self.__set_wind_speed_function(data_array[:, (1, 7)])
 
-        # Retrieve station elevation from station info
-        station_elevation_text = station_info.split("\n")[6]
-
-        # Convert station elevation text into float value
-        self.elevation = float(
-            re.findall(r"[0-9]+\.[0-9]+|[0-9]+", station_elevation_text)[0]
-        )
+        # Retrieve station elevation
+        if station_info is not None:
+            # Legacy pages: read it from the station information block
+            station_elevation_text = station_info.split("\n")[6]
+            self.elevation = float(
+                re.findall(r"[0-9]+\.[0-9]+|[0-9]+", station_elevation_text)[0]
+            )
+        else:
+            # Current WSGI pages: use the surface (first) level height
+            self.elevation = float(data_array[0, 1])
 
         # Save maximum expected height
         self._max_expected_height = data_array[-1, 1]
diff --git a/rocketpy/exceptions.py b/rocketpy/exceptions.py
index e85bec95e..e6bdc4355 100644
--- a/rocketpy/exceptions.py
+++ b/rocketpy/exceptions.py
@@ -1,5 +1,10 @@
 """Custom exceptions and warnings for RocketPy."""
 
+# TODO: progressively adopt these custom exceptions across the codebase.
+# Many modules still ``raise ValueError``/``TypeError`` directly; migrate those
+# to the appropriate ``RocketPyError`` subclass (adding new exception types here
+# as needed) so users can reliably catch ``RocketPyError`` and its subclasses.
+
 
 class RocketPyError(Exception):
     """Base class for all RocketPy exceptions."""
diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py
index 8fbea1ba9..e255831be 100644
--- a/rocketpy/plots/flight_plots.py
+++ b/rocketpy/plots/flight_plots.py
@@ -7,7 +7,6 @@
 import matplotlib.pyplot as plt
 import numpy as np
 
-from ..mathutils import Function
 from ..tools import import_optional_dependency
 from .plot_helpers import show_or_save_plot
 
@@ -237,6 +236,27 @@ def _create_animation_box(self, start, scale=1.0):
             height=height,
         ).wireframe()
 
+    @staticmethod
+    def _require_interactive_vedo_backend(vedo):
+        """Ensure vedo can open an interactive VTK window for the animation.
+
+        The animations drive an interactive render loop, which needs a desktop
+        window. In Jupyter/headless environments vedo defaults to the ``"2d"``
+        backend, where every ``render()`` call fails with the cryptic
+        ``"No active Plotter found for the 2d backend"``. Fail early with a
+        clear, actionable message instead.
+        """
+        if getattr(vedo.settings, "default_backend", None) == "2d":
+            raise RuntimeError(
+                "Rocket animations require an interactive VTK window, which is "
+                "not available with vedo's '2d' backend (the default inside "
+                "Jupyter notebooks and headless environments). Run this from a "
+                "Python script, or switch vedo to a desktop backend before "
+                "calling it:\n"
+                "    import vedo\n"
+                "    vedo.settings.default_backend = 'vtk'"
+            )
+
     def animate_trajectory(  # pylint: disable=too-many-statements
         self, file_name=None, start=0, stop=None, time_step=0.1, **kwargs
     ):
@@ -261,6 +281,7 @@ def animate_trajectory(  # pylint: disable=too-many-statements
         """
 
         vedo = import_optional_dependency("vedo")
+        self._require_interactive_vedo_backend(vedo)
 
         file_name = self._resolve_animation_model_path(file_name)
         stop = self._validate_animation_inputs(file_name, start, stop, time_step)
@@ -343,6 +364,7 @@ def animate_rotate(  # pylint: disable=too-many-statements
         """
 
         vedo = import_optional_dependency("vedo")
+        self._require_interactive_vedo_backend(vedo)
 
         file_name = self._resolve_animation_model_path(file_name)
         stop = self._validate_animation_inputs(file_name, start, stop, time_step)
@@ -1032,6 +1054,18 @@ def energy_data(self, *, filename=None):  # pylint: disable=too-many-statements
         plt.subplots_adjust(hspace=1)
         show_or_save_plot(filename)
 
+    @staticmethod
+    def __signed_angle_ylim(angle_function, t_start, t_end, margin=5):
+        """Return ``(ymin, ymax)`` limits for a signed angle plotted between
+        ``t_start`` and ``t_end``. The range is based only on the samples inside
+        that time window so the full (positive and negative) oscillation is
+        visible, unlike a fixed floor at zero which would clip it.
+        """
+        data = angle_function[:, :]
+        mask = (data[:, 0] >= t_start) & (data[:, 0] <= t_end)
+        values = data[mask, 1] if np.any(mask) else data[:, 1]
+        return values.min() - margin, values.max() + margin
+
     def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-statements
         """Prints out a summary of the Fluid Mechanics graphs available about
         the Flight
@@ -1109,8 +1143,15 @@ def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-st
         ax5.set_xlabel("Time (s)")
         ax5.set_ylabel("Partial Angle of Attack (°)")
         ax5.set_xlim(self.flight.out_of_rail_time, self.first_event_time)
+        # Partial angle of attack is a signed angle oscillating around zero, so
+        # scale to the data in the plotted window instead of flooring at 0
+        # (which would clip the negative half of the oscillation).
         ax5.set_ylim(
-            0, self.flight.partial_angle_of_attack(self.flight.out_of_rail_time) + 15
+            *self.__signed_angle_ylim(
+                self.flight.partial_angle_of_attack,
+                self.flight.out_of_rail_time,
+                self.first_event_time,
+            )
         )
         ax5.grid()
 
@@ -1122,8 +1163,13 @@ def fluid_mechanics_data(self, *, filename=None):  # pylint: disable=too-many-st
         ax6.set_xlabel("Time (s)")
         ax6.set_ylabel("Angle of Sideslip (°)")
         ax6.set_xlim(self.flight.out_of_rail_time, self.first_event_time)
+        # Sideslip is also signed; keep the full oscillation visible.
         ax6.set_ylim(
-            0, self.flight.angle_of_sideslip(self.flight.out_of_rail_time) + 15
+            *self.__signed_angle_ylim(
+                self.flight.angle_of_sideslip,
+                self.flight.out_of_rail_time,
+                self.first_event_time,
+            )
         )
         ax6.grid()
 
@@ -1263,14 +1309,35 @@ def pressure_signals(self):
         None
         """
 
-        if len(self.flight.parachute_events) > 0:
-            for parachute in self.flight.rocket.parachutes:
-                print(f"\nParachute: {parachute.name}")
-                parachute.noise_signal_function()
-                parachute.noisy_pressure_signal_function()
-                parachute.clean_pressure_signal_function()
-        else:
+        if len(self.flight.parachute_events) == 0:
             logger.warning("Rocket has no parachutes. No parachute plots available.")
+            return
+
+        for parachute in self.flight.rocket.parachutes:
+            clean = parachute.clean_pressure_signal_function
+            noisy = parachute.noisy_pressure_signal_function
+            # Nothing was recorded (e.g. parachute never triggered)
+            if not isinstance(clean.source, np.ndarray) or clean.source.ndim != 2:
+                continue
+            time_signal = clean.source[:, 0]
+
+            plt.figure(figsize=(9, 4))
+            plt.plot(
+                time_signal, clean(time_signal), label="Without noise", linewidth=1.5
+            )
+            plt.plot(
+                time_signal,
+                noisy(time_signal),
+                label="With noise",
+                alpha=0.7,
+                linewidth=0.8,
+            )
+            plt.title(f"Parachute trigger pressure signal: {parachute.name}")
+            plt.xlabel("Time (s)")
+            plt.ylabel("Pressure (Pa)")
+            plt.legend()
+            plt.grid(True)
+            show_or_save_plot()
 
     def parachutes_info(self, parachute_name="all"):
         """Plots parachute dynamic information.
@@ -1312,19 +1379,43 @@ def parachutes_info(self, parachute_name="all"):
             )
             return
 
+        # Gather every dynamic variable across the selected parachutes so each
+        # one is drawn on a single figure with all parachutes overlaid.
+        variables = []
+        for _, parachute_variables in items:
+            for variable in parachute_variables:
+                if variable != "t" and variable not in variables:
+                    variables.append(variable)
+
+        for variable in variables:
+            self.__plot_parachute_variable(variable, items)
+
+    # Axis label (and unit) for each known parachute dynamic variable.
+    __parachute_variable_labels = {"drag": ("Drag Force", "N")}
+
+    def __plot_parachute_variable(self, variable, items):
+        """Plot a single parachute dynamic variable (e.g. drag) for every
+        parachute in ``items`` overlaid on one figure, one colour each."""
+        label, unit = self.__parachute_variable_labels.get(
+            variable, (variable.replace("_", " ").title(), "")
+        )
+        ylabel = f"{label} ({unit})" if unit else label
+
+        plt.figure(figsize=(9, 4))
         for name, parachute_variables in items:
-            t_values = parachute_variables["t"]
-            for variable, variable_values in parachute_variables.items():
-                if variable == "t":
-                    continue
-                variable_func = Function(
-                    source=[[t, value] for t, value in zip(t_values, variable_values)],
-                    inputs="time",
-                    outputs=variable,
-                    title=f"{variable} x time for parachute {name}",
-                    interpolation="linear",
-                )
-                variable_func()
+            if variable not in parachute_variables:
+                continue
+            plt.plot(
+                parachute_variables["t"],
+                parachute_variables[variable],
+                label=name,
+            )
+        plt.title(f"Parachute {label} vs Time")
+        plt.xlabel("Time (s)")
+        plt.ylabel(ylabel)
+        plt.legend()
+        plt.grid(True)
+        show_or_save_plot()
 
     def all(self):  # pylint: disable=too-many-statements
         """Prints out all plots available about the Flight.
diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py
index ed1c14917..66345f2cb 100644
--- a/rocketpy/rocket/aero_surface/fins/elliptical_fin.py
+++ b/rocketpy/rocket/aero_surface/fins/elliptical_fin.py
@@ -176,9 +176,11 @@ def evaluate_center_of_pressure(self):
         self.cpz = cpz
         self.cp = (self.cpx, self.cpy, self.cpz)
 
-    def to_dict(self, include_outputs=False):
-        data = super().to_dict(include_outputs=include_outputs)
-        data.update(self.geometry.get_data(include_outputs=include_outputs))
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
diff --git a/rocketpy/rocket/aero_surface/fins/fin.py b/rocketpy/rocket/aero_surface/fins/fin.py
index d6e395ffb..bb41c89d2 100644
--- a/rocketpy/rocket/aero_surface/fins/fin.py
+++ b/rocketpy/rocket/aero_surface/fins/fin.py
@@ -430,18 +430,30 @@ def _compute_leading_edge_position(self, position, _csys):
         position += p
         return position
 
-    def to_dict(self, include_outputs=False):
+    def to_dict(self, **kwargs):
+        if self.airfoil:
+            if kwargs.get("discretize", False):
+                lower = -np.pi / 6 if self.airfoil[1] == "radians" else -30
+                upper = np.pi / 6 if self.airfoil[1] == "radians" else 30
+                airfoil = (
+                    self.airfoil_cl.set_discrete(lower, upper, 50, mutate_self=False),
+                    self.airfoil[1],
+                )
+            else:
+                airfoil = (self.airfoil_cl, self.airfoil[1])
+        else:
+            airfoil = None
         data = {
             "angular_position": self.angular_position,
             "root_chord": self.root_chord,
             "span": self.span,
             "rocket_radius": self.rocket_radius,
             "cant_angle": self.cant_angle,
-            "airfoil": self.airfoil,
+            "airfoil": airfoil,
             "name": self.name,
         }
 
-        if include_outputs:
+        if kwargs.get("include_outputs", False):
             data.update(
                 {
                     "cp": self.cp,
diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fin.py b/rocketpy/rocket/aero_surface/fins/free_form_fin.py
index fef8ac785..767b1bd89 100644
--- a/rocketpy/rocket/aero_surface/fins/free_form_fin.py
+++ b/rocketpy/rocket/aero_surface/fins/free_form_fin.py
@@ -173,9 +173,11 @@ def evaluate_center_of_pressure(self):
     def shape_points(self):
         return self.geometry.shape_points
 
-    def to_dict(self, include_outputs=False):
-        data = super().to_dict(include_outputs=include_outputs)
-        data.update(self.geometry.get_data(include_outputs=include_outputs))
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py
index c58055945..872aebe97 100644
--- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py
+++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fin.py
@@ -220,9 +220,11 @@ def evaluate_center_of_pressure(self):
         self.cpz = cpz
         self.cp = (self.cpx, self.cpy, self.cpz)
 
-    def to_dict(self, include_outputs=False):
-        data = super().to_dict(include_outputs=include_outputs)
-        data.update(self.geometry.get_data(include_outputs=include_outputs))
+    def to_dict(self, **kwargs):
+        data = super().to_dict(**kwargs)
+        data.update(
+            self.geometry.get_data(include_outputs=kwargs.get("include_outputs", False))
+        )
         return data
 
     @classmethod
diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py
index ede0009b6..14ba6e737 100644
--- a/rocketpy/simulation/flight.py
+++ b/rocketpy/simulation/flight.py
@@ -3963,10 +3963,17 @@ def __transform_pressure_signals_lists_to_functions(self):
             parachute.noise_signal_function = Function(
                 parachute.noise_signal, "Time (s)", "Pressure Noise (Pa)", "linear"
             )
+            # Function arithmetic drops the axis labels/title, so restore them
+            # to keep the pressure-signal plots readable (see pressure_signals).
             parachute.noisy_pressure_signal_function = (
                 parachute.clean_pressure_signal_function
                 + parachute.noise_signal_function
             )
+            parachute.noisy_pressure_signal_function.set_inputs("Time (s)")
+            parachute.noisy_pressure_signal_function.set_outputs(
+                "Pressure - With Noise (Pa)"
+            )
+            parachute.noisy_pressure_signal_function.set_title("Noisy Pressure Signal")
 
     @cached_property
     def __evaluate_post_process(self):
diff --git a/rocketpy/utilities.py b/rocketpy/utilities.py
index 3663f829b..4c76180fb 100644
--- a/rocketpy/utilities.py
+++ b/rocketpy/utilities.py
@@ -1,5 +1,6 @@
 import inspect
 import json
+import logging
 import os
 import warnings
 from datetime import date
@@ -22,6 +23,57 @@
     from .simulation.flight import Flight
 
 
+def enable_logging(level="WARNING"):
+    """Enable RocketPy logging output to the console.
+
+    Attaches a StreamHandler to the ``rocketpy`` logger so that internal
+    runtime events (simulation progress, warnings, errors) are printed to
+    the terminal. Only RocketPy logs are affected — global/root logging
+    is not modified. By default, only WARNING and above are shown.
+
+    Parameters
+    ----------
+    level : str, optional
+        The minimum logging level to display. Options are "DEBUG", "INFO",
+        "WARNING", "ERROR", and "CRITICAL". Default is "WARNING".
+
+    Examples
+    --------
+    Show only warnings and errors (default):
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging()
+
+    Show all internal runtime messages, including simulation progress:
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging(level="DEBUG")
+
+    Show confirmations like "Simulation completed" and "File saved":
+
+    >>> import rocketpy
+    >>> rocketpy.utilities.enable_logging(level="INFO")
+    """
+    numeric_level = getattr(logging, level.upper(), None)
+    if not isinstance(numeric_level, int):
+        raise ValueError(f"Invalid logging level: '{level}'")
+
+    logger = logging.getLogger("rocketpy")
+
+    # Remove any existing StreamHandlers to avoid duplicate messages
+    logger.handlers = [
+        h for h in logger.handlers if not isinstance(h, logging.StreamHandler)
+    ]
+
+    logger.setLevel(numeric_level)
+
+    handler = logging.StreamHandler()
+    handler.setLevel(numeric_level)
+    handler.setFormatter(logging.Formatter("%(levelname)s | %(name)s | %(message)s"))
+
+    logger.addHandler(handler)
+
+
 def compute_cd_s_from_drop_test(
     terminal_velocity, rocket_mass, air_density=1.225, g=9.80665
 ):
diff --git a/rocketpy/utils.py b/rocketpy/utils.py
deleted file mode 100644
index 51addfe4b..000000000
--- a/rocketpy/utils.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import logging
-
-
-def enable_logging(level="WARNING"):
-    """Enable RocketPy logging output to the console.
-
-    Attaches a StreamHandler to the ``rocketpy`` logger so that internal
-    runtime events (simulation progress, warnings, errors) are printed to
-    the terminal. Only RocketPy logs are affected — global/root logging
-    is not modified. By default, only WARNING and above are shown.
-
-    Parameters
-    ----------
-    level : str, optional
-        The minimum logging level to display. Options are "DEBUG", "INFO",
-        "WARNING", "ERROR", and "CRITICAL". Default is "WARNING".
-
-    Examples
-    --------
-    Show only warnings and errors (default):
-
-    >>> import rocketpy
-    >>> rocketpy.utils.enable_logging()
-
-    Show all internal runtime messages, including simulation progress:
-
-    >>> import rocketpy
-    >>> rocketpy.utils.enable_logging(level="DEBUG")
-
-    Show confirmations like "Simulation completed" and "File saved":
-
-    >>> import rocketpy
-    >>> rocketpy.utils.enable_logging(level="INFO")
-    """
-    numeric_level = getattr(logging, level.upper(), None)
-    if not isinstance(numeric_level, int):
-        raise ValueError(f"Invalid logging level: '{level}'")
-
-    logger = logging.getLogger("rocketpy")
-
-    # Remove any existing StreamHandlers to avoid duplicate messages
-    logger.handlers = [
-        h for h in logger.handlers if not isinstance(h, logging.StreamHandler)
-    ]
-
-    logger.setLevel(numeric_level)
-
-    handler = logging.StreamHandler()
-    handler.setLevel(numeric_level)
-    handler.setFormatter(logging.Formatter("%(levelname)s | %(name)s | %(message)s"))
-
-    logger.addHandler(handler)
diff --git a/tests/integration/environment/test_environment.py b/tests/integration/environment/test_environment.py
index ddd6e70c3..d51551397 100644
--- a/tests/integration/environment/test_environment.py
+++ b/tests/integration/environment/test_environment.py
@@ -308,21 +308,27 @@ def test_wyoming_sounding_atmosphere(mock_show, example_plain_env):  # pylint: d
 
     # TODO:: this should be added to the set_atmospheric_model() method as a
     #        "file" option, instead of receiving the URL as a string.
-    url = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779"
-    # give it at least 5 times to try to download the file
+    url = (
+        "https://weather.uwyo.edu/wsgi/sounding?"
+        "datetime=2019-02-05+00:00:00&id=83779&type=TEXT:LIST"
+    )
+    # give it at least 5 times to try to download the file, then skip instead
+    # of silently keeping the standard atmosphere and failing the assertions
     for i in range(5):
         try:
             example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=url)
             break
         except Exception:  # pylint: disable=broad-except
             time.sleep(2**i)
+    else:
+        pytest.skip("Could not fetch Wyoming sounding data from weather.uwyo.edu")
     assert example_plain_env.all_info() is None
     assert abs(example_plain_env.pressure(0) - 93600.0) < 1e-8
     assert (
         abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0)
         < 1e-8
     )
-    assert abs(example_plain_env.wind_velocity_x(0) - -2.9005178894925043) < 1e-8
+    assert abs(example_plain_env.wind_velocity_x(0) - -2.9130471244363165) < 1e-8
     assert abs(example_plain_env.temperature(100) - 291.75) < 1e-8
 
 
diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py
index cd2de7ee1..dc3be3432 100644
--- a/tests/integration/test_encoding.py
+++ b/tests/integration/test_encoding.py
@@ -277,6 +277,45 @@ def test_trapezoidal_fins_encoder(fin_name, request):
         assert np.isclose(fin_to_encode.sweep_angle, fin_loaded.sweep_angle)
 
 
+@pytest.mark.parametrize(
+    "fin_name",
+    [
+        "calisto_trapezoidal_fin",
+        "calisto_elliptical_fin",
+        "calisto_free_form_fin",
+    ],
+)
+@pytest.mark.parametrize("include_outputs", [False, True])
+def test_individual_fin_encoder(fin_name, include_outputs, request):
+    """Test encoding an individual fin (``TrapezoidalFin``, ``EllipticalFin``
+    or ``FreeFormFin``).
+
+    Parameters
+    ----------
+    fin_name : str
+        Name of the fin fixture to encode.
+    include_outputs : bool
+        Whether to include outputs in the encoding.
+    request : pytest.FixtureRequest
+        Pytest request object.
+    """
+    fin_to_encode = request.getfixturevalue(fin_name)
+
+    json_encoded = json.dumps(
+        fin_to_encode, cls=RocketPyEncoder, include_outputs=include_outputs
+    )
+
+    fin_loaded = json.loads(json_encoded, cls=RocketPyDecoder)
+
+    assert isinstance(fin_loaded, type(fin_to_encode))
+    assert np.isclose(fin_to_encode.angular_position, fin_loaded.angular_position)
+    assert np.isclose(fin_to_encode.span, fin_loaded.span)
+    assert np.isclose(fin_to_encode.root_chord, fin_loaded.root_chord)
+    assert np.isclose(fin_to_encode.rocket_radius, fin_loaded.rocket_radius)
+    assert np.isclose(fin_to_encode.cant_angle, fin_loaded.cant_angle)
+    assert np.allclose(fin_to_encode.cp, fin_loaded.cp)
+
+
 @pytest.mark.parametrize("rocket_name", ["calisto_robust", "calisto_hybrid_modded"])
 def test_encoder_discretize(rocket_name, request):
     """Test encoding the total mass of ``rocketpy.Rocket`` with
diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py
index 8da56b646..6b2eb7d5a 100644
--- a/tests/unit/test_utilities.py
+++ b/tests/unit/test_utilities.py
@@ -1,3 +1,4 @@
+import logging
 import os
 from unittest.mock import patch
 
@@ -345,3 +346,79 @@ def test_load_from_rpy(mock_show):  # pylint: disable=unused-argument
     )
     assert loaded_flight.info() is None
     assert loaded_flight.all_info() is None
+
+
+# --- Logging (rocketpy.utilities.enable_logging) ------------------------------
+
+
+@pytest.fixture(autouse=True)
+def reset_rocketpy_logger():
+    """Reset the rocketpy logger to its original state after each test."""
+    logger = logging.getLogger("rocketpy")
+    original_level = logger.level
+    original_handlers = logger.handlers[:]
+    yield
+    logger.handlers = original_handlers
+    logger.setLevel(original_level)
+
+
+def test_enable_logging_adds_stream_handler():
+    """enable_logging() must attach a StreamHandler to the rocketpy logger."""
+    utilities.enable_logging(level="INFO")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) >= 1
+
+
+def test_enable_logging_sets_correct_level():
+    """enable_logging() must set the requested level on the rocketpy logger."""
+    utilities.enable_logging(level="DEBUG")
+    assert logging.getLogger("rocketpy").level == logging.DEBUG
+
+    utilities.enable_logging(level="WARNING")
+    assert logging.getLogger("rocketpy").level == logging.WARNING
+
+
+def test_enable_logging_no_duplicate_handlers():
+    """Calling enable_logging() twice must not duplicate StreamHandlers."""
+    utilities.enable_logging(level="INFO")
+    utilities.enable_logging(level="INFO")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) == 1
+
+
+def test_enable_logging_replaces_handler_on_level_change():
+    """Calling enable_logging() with a new level must replace the old handler."""
+    utilities.enable_logging(level="WARNING")
+    utilities.enable_logging(level="DEBUG")
+
+    logger = logging.getLogger("rocketpy")
+    stream_handlers = [
+        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
+    ]
+    assert len(stream_handlers) == 1
+    assert logger.level == logging.DEBUG
+
+
+def test_enable_logging_invalid_level_raises():
+    """enable_logging() must raise ValueError for an unrecognised level string."""
+    with pytest.raises(ValueError, match="Invalid logging level"):
+        utilities.enable_logging(level="INVALID")
+
+
+def test_enable_logging_messages_are_captured(caplog):
+    """After enable_logging(), internal rocketpy log messages must be visible."""
+    utilities.enable_logging(level="DEBUG")
+
+    with caplog.at_level(logging.DEBUG, logger="rocketpy"):
+        logger = logging.getLogger("rocketpy.simulation.flight")
+        logger.info("test message from flight")
+
+    assert "test message from flight" in caplog.text
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
deleted file mode 100644
index 983a5b3dd..000000000
--- a/tests/unit/test_utils.py
+++ /dev/null
@@ -1,80 +0,0 @@
-"""Unit tests for rocketpy.utils module."""
-
-import logging
-
-import pytest
-
-import rocketpy
-
-
-@pytest.fixture(autouse=True)
-def reset_rocketpy_logger():
-    """Reset the rocketpy logger to its original state after each test."""
-    logger = logging.getLogger("rocketpy")
-    original_level = logger.level
-    original_handlers = logger.handlers[:]
-    yield
-    logger.handlers = original_handlers
-    logger.setLevel(original_level)
-
-
-def test_enable_logging_adds_stream_handler():
-    """enable_logging() must attach a StreamHandler to the rocketpy logger."""
-    rocketpy.utils.enable_logging(level="INFO")
-
-    logger = logging.getLogger("rocketpy")
-    stream_handlers = [
-        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
-    ]
-    assert len(stream_handlers) >= 1
-
-
-def test_enable_logging_sets_correct_level():
-    """enable_logging() must set the requested level on the rocketpy logger."""
-    rocketpy.utils.enable_logging(level="DEBUG")
-    assert logging.getLogger("rocketpy").level == logging.DEBUG
-
-    rocketpy.utils.enable_logging(level="WARNING")
-    assert logging.getLogger("rocketpy").level == logging.WARNING
-
-
-def test_enable_logging_no_duplicate_handlers():
-    """Calling enable_logging() twice must not duplicate StreamHandlers."""
-    rocketpy.utils.enable_logging(level="INFO")
-    rocketpy.utils.enable_logging(level="INFO")
-
-    logger = logging.getLogger("rocketpy")
-    stream_handlers = [
-        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
-    ]
-    assert len(stream_handlers) == 1
-
-
-def test_enable_logging_replaces_handler_on_level_change():
-    """Calling enable_logging() with a new level must replace the old handler."""
-    rocketpy.utils.enable_logging(level="WARNING")
-    rocketpy.utils.enable_logging(level="DEBUG")
-
-    logger = logging.getLogger("rocketpy")
-    stream_handlers = [
-        h for h in logger.handlers if isinstance(h, logging.StreamHandler)
-    ]
-    assert len(stream_handlers) == 1
-    assert logger.level == logging.DEBUG
-
-
-def test_enable_logging_invalid_level_raises():
-    """enable_logging() must raise ValueError for an unrecognised level string."""
-    with pytest.raises(ValueError, match="Invalid logging level"):
-        rocketpy.utils.enable_logging(level="INVALID")
-
-
-def test_enable_logging_messages_are_captured(caplog):
-    """After enable_logging(), internal rocketpy log messages must be visible."""
-    rocketpy.utils.enable_logging(level="DEBUG")
-
-    with caplog.at_level(logging.DEBUG, logger="rocketpy"):
-        logger = logging.getLogger("rocketpy.simulation.flight")
-        logger.info("test message from flight")
-
-    assert "test message from flight" in caplog.text