From d41dd11efcae7234846b199d9d9b5cdcad17b933 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR <63590233+Gui-FernandesBR@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:07:12 -0300 Subject: [PATCH 01/12] MNT: add Renovate config to master so it actually takes effect (#1039) Renovate (Mend hosted app) reads repository configuration only from the default branch, which is `master`. The config introduced in #972 was merged into `develop`, so it never took effect: Renovate ran with bare defaults, raised PRs against `master` instead of `develop`, did not group updates, and naively bumped every pinned line in the autogenerated `docs/requirements.txt`. This adds the configuration on `master` and: - keeps the intended settings (baseBranches: develop, grouping, labels, dependency dashboard); - enables the `pip-compile` manager scoped to `docs/requirements.txt`, which is autogenerated from `docs/requirements.in`, so the lock file is regenerated properly instead of patched line by line; - disables the `pip_requirements` manager for that one file to avoid duplicate PRs (it still manages the hand-written requirements files). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/renovate.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/renovate.json diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..1d2808213 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "baseBranches": [ + "develop" + ], + "labels": [ + "dependencies" + ], + "dependencyDashboard": true, + "automerge": false, + "pip-compile": { + "managerFilePatterns": [ + "/^docs/requirements\\.txt$/" + ] + }, + "packageRules": [ + { + "matchPackagePatterns": [ + "*" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "all non-major dependencies", + "groupSlug": "all-non-major" + }, + { + "description": "docs/requirements.txt is autogenerated by pip-compile from docs/requirements.in. Let the pip-compile manager regenerate the lock file, and stop the pip_requirements manager from editing the autogenerated file line by line.", + "matchManagers": [ + "pip_requirements" + ], + "matchFileNames": [ + "docs/requirements.txt" + ], + "enabled": false + } + ] +} From 6d80070b562a1280175ab4b6c66f5fc8255bd1ab Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 20:31:23 -0300 Subject: [PATCH 02/12] REL: bumps up rocketpy version to 1.13.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/conf.py | 2 +- docs/user/installation.rst | 2 +- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fde7bddd..3716b26a1 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: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) - ENH: DEV: Claude Code ruff hooks (auto-format on edit + pre-push lint guard) [#1046](https://github.com/RocketPy-Team/RocketPy/pull/1046) - ENH: CI: create a CI for testing docs updates + solve different docs issues [#1045](https://github.com/RocketPy-Team/RocketPy/pull/1045) @@ -58,6 +67,8 @@ Attention: The newest changes should be on top --> ### Changed +- REL: bumps up rocketpy version to 1.13.0 [#XXXX](https://github.com/RocketPy-Team/RocketPy/pull/XXXX) + ### Deprecated - MNT: Rename `radius` to `radius_function` in `CylindricalTank` and `SphericalTank`; old `radius=` keyword argument now raises `DeprecationWarning` [#957](https://github.com/RocketPy-Team/RocketPy/pull/957) 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/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/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" From 0df08ba7dab22c41c68975abad0dff264f390215 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Marinho Bressan <87212571+phmbressan@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:56:09 +0200 Subject: [PATCH 03/12] MNT: Fix typo in README regarding KML export --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f83d554171d66e27925004de8949c63404a6802a Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Wed, 8 Jul 2026 23:22:37 -0300 Subject: [PATCH 04/12] BUG: fix individual fin to_dict signature to accept encoder kwargs RocketPyEncoder always passes include_outputs, discretize and allow_pickle to to_dict, but Fin/TrapezoidalFin/EllipticalFin/FreeFormFin only accepted include_outputs, so serializing any rocket or flight containing an individual fin raised TypeError. Signatures now mirror the fin-set classes (**kwargs), and the airfoil is serialized as the reconstructable (airfoil_cl, unit) pair like the set classes do. Co-Authored-By: Claude Fable 5 --- .../aero_surface/fins/elliptical_fin.py | 8 ++-- rocketpy/rocket/aero_surface/fins/fin.py | 18 +++++++-- .../rocket/aero_surface/fins/free_form_fin.py | 8 ++-- .../aero_surface/fins/trapezoidal_fin.py | 8 ++-- tests/integration/test_encoding.py | 39 +++++++++++++++++++ 5 files changed, 69 insertions(+), 12 deletions(-) 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/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 From 2123d2467e9ccefb5695cf11a15f96ef4116c28f Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Wed, 8 Jul 2026 23:31:34 -0300 Subject: [PATCH 05/12] BUG: support new Wyoming sounding WSGI page format The University of Wyoming discontinued the legacy cgi-bin sounding endpoint (it now redirects to a 404), which made test_wyoming_sounding_atmosphere silently fall back to the standard atmosphere and fail. process_wyoming_sounding now supports the new WSGI page format as well: single
 block (no station-info block, elevation
taken from the surface level row) and wind speed reported as SPED in m/s
instead of SKNT in knots. Legacy-format pages are still parsed as before.

The test now uses the new URL, skips loudly when the service is
unreachable instead of silently keeping the standard atmosphere, and
updates the expected wind value to the m/s-precision data. Docs examples
updated to the new URL format.

Co-Authored-By: Claude Fable 5 
---
 docs/development/testing.rst                  |  5 ++-
 .../environment/1-atm-models/soundings.rst    | 15 ++++++---
 rocketpy/environment/environment.py           | 31 ++++++++++++-------
 .../environment/test_environment.py           | 12 +++++--
 4 files changed, 41 insertions(+), 22 deletions(-)

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/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/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/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
 
 

From c0998c84ae2c95d03d2142d1cf76533a9d9ef848 Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Wed, 8 Jul 2026 23:32:29 -0300
Subject: [PATCH 06/12] MNT: update CHANGELOG with PR link, breaking changes
 and new fixes

Co-Authored-By: Claude Fable 5 
---
 CHANGELOG.md | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3716b26a1..fd1fe4c64 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,7 +67,11 @@ Attention: The newest changes should be on top -->
 
 ### Changed
 
-- REL: bumps up rocketpy version to 1.13.0 [#XXXX](https://github.com/RocketPy-Team/RocketPy/pull/XXXX)
+- 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)
 
 ### Deprecated
 
@@ -79,6 +83,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)

From f9b6b219c5e082234b93d3255ecb0d34dc6d4e75 Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 15:04:43 -0300
Subject: [PATCH 07/12] BUG: refine Flight parachute/aerodynamic plots from
 notebook review

Fixes four rough edges found while running the getting_started notebook:

- Partial Angle of Attack and Angle of Sideslip are signed angles that
  oscillate around zero, but all_info clamped their y-axis floor to 0,
  clipping the negative half and making the damped oscillation look
  erratic. They now scale to the data in the plotted window.
- The noisy pressure signal (clean + noise Function) lost its axis
  labels/title, rendering as "Scalar x X". Labels are now restored, and
  pressure_signals draws a single clean-vs-noisy overlay per parachute
  instead of three separate plots (dropping the useless noise-only plot).
- parachutes_info now draws one figure per dynamic variable with all
  parachutes overlaid in different colors and properly-labelled axes
  with units (e.g. "Drag Force (N)" vs "Time (s)").
- animate_trajectory/animate_rotate now raise a clear, actionable error
  when vedo is on its non-interactive "2d" backend (Jupyter/headless)
  instead of spamming "No active Plotter found for the 2d backend".

Co-Authored-By: Claude Opus 4.8 
---
 rocketpy/plots/flight_plots.py | 128 +++++++++++++++++++++++++++------
 rocketpy/simulation/flight.py  |   7 ++
 2 files changed, 114 insertions(+), 21 deletions(-)

diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py
index 8fbea1ba9..c1c3f0ff3 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,38 @@ def parachutes_info(self, parachute_name="all"):
             )
             return
 
-        for name, parachute_variables in items:
-            t_values = parachute_variables["t"]
-            for variable, variable_values in parachute_variables.items():
-                if variable == "t":
+        # Axis label (and unit) for each known dynamic variable.
+        variable_labels = {"drag": ("Drag Force", "N")}
+
+        # 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:
+            label, unit = 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:
+                if variable not in parachute_variables:
                     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",
+                plt.plot(
+                    parachute_variables["t"],
+                    parachute_variables[variable],
+                    label=name,
                 )
-                variable_func()
+            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/simulation/flight.py b/rocketpy/simulation/flight.py
index 167364477..cda75766b 100644
--- a/rocketpy/simulation/flight.py
+++ b/rocketpy/simulation/flight.py
@@ -3973,10 +3973,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):

From fced11624f07f7015a4804508fcdc4f784c2fff2 Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 17:23:36 -0300
Subject: [PATCH 08/12] MNT: extract parachute-variable plot helper to satisfy
 pylint

parachutes_info exceeded pylint's too-many-statements limit (26/25) after
the combined-plot rework. Move the per-variable figure building into a
__plot_parachute_variable helper, which also makes the method easier to read.

Co-Authored-By: Claude Opus 4.8 
---
 rocketpy/plots/flight_plots.py | 49 +++++++++++++++++++---------------
 1 file changed, 27 insertions(+), 22 deletions(-)

diff --git a/rocketpy/plots/flight_plots.py b/rocketpy/plots/flight_plots.py
index c1c3f0ff3..e255831be 100644
--- a/rocketpy/plots/flight_plots.py
+++ b/rocketpy/plots/flight_plots.py
@@ -1379,9 +1379,6 @@ def parachutes_info(self, parachute_name="all"):
             )
             return
 
-        # Axis label (and unit) for each known dynamic variable.
-        variable_labels = {"drag": ("Drag Force", "N")}
-
         # Gather every dynamic variable across the selected parachutes so each
         # one is drawn on a single figure with all parachutes overlaid.
         variables = []
@@ -1391,26 +1388,34 @@ def parachutes_info(self, parachute_name="all"):
                     variables.append(variable)
 
         for variable in variables:
-            label, unit = variable_labels.get(
-                variable, (variable.replace("_", " ").title(), "")
-            )
-            ylabel = f"{label} ({unit})" if unit else label
+            self.__plot_parachute_variable(variable, items)
 
-            plt.figure(figsize=(9, 4))
-            for name, parachute_variables in items:
-                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()
+    # 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:
+            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.

From 9d4e01e60fa9211178e2e2223b0bba3bfbda7f17 Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 17:34:34 -0300
Subject: [PATCH 09/12] DOC: split controllers into a dedicated user-guide page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses the review suggestion to separate controllers from air brakes in
the documentation. The generic controller material — the controller-function
signature and argument list, the 6/7/8-parameter backward-compatibility notes,
the environment argument, and the discrete vs. continuous controller behaviour
— now lives in a new docs/user/controllers.rst page. airbrakes.rst keeps the
concrete air-brakes example (which still defines its own controller_function so
its jupyter-execute cells run) and cross-references the new page. The
discrete-vs-continuous-controllers label moved to the new page; existing refs
to it resolve unchanged.

Co-Authored-By: Claude Opus 4.8 
---
 docs/user/airbrakes.rst   | 119 +++++-----------------------------
 docs/user/controllers.rst | 132 ++++++++++++++++++++++++++++++++++++++
 docs/user/index.rst       |   1 +
 3 files changed, 149 insertions(+), 103 deletions(-)
 create mode 100644 docs/user/controllers.rst

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/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

From 6f248baa5c5f588daeffe499790fa0e35bc47c6c Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 17:43:44 -0300
Subject: [PATCH 10/12] DOC: add TODO to migrate raw raises to custom RocketPy
 exceptions

Addresses the review note in exceptions.py: reminds contributors to
progressively adopt the custom RocketPyError subclasses across the codebase
(and add new exception types here) instead of raising bare ValueError/TypeError.

Co-Authored-By: Claude Opus 4.8 
---
 rocketpy/exceptions.py | 5 +++++
 1 file changed, 5 insertions(+)

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."""

From ded9c9a6e0a99c4f2bf79c32fb345f9c3b88159e Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 17:44:00 -0300
Subject: [PATCH 11/12] MNT: move enable_logging from utils.py into
 utilities.py

Removes the third helper module flagged in review. enable_logging is a
user-facing convenience, so it belongs in the public utilities module
alongside the other analysis helpers rather than in a standalone utils.py.

- rocketpy.utils.enable_logging -> rocketpy.utilities.enable_logging
- delete rocketpy/utils.py; expose rocketpy.utilities via __init__ (imported
  last, since utilities pulls in Environment/Rocket/encoders)
- move the logging unit tests into test_utilities.py; delete test_utils.py
- update the logging user guide and reference pages

Co-Authored-By: Claude Opus 4.8 
---
 docs/reference/classes/utils/logging.rst   |  3 +-
 docs/reference/classes/utils/utilities.rst |  3 +-
 docs/user/logging.rst                      |  8 +--
 rocketpy/__init__.py                       |  6 +-
 rocketpy/utilities.py                      | 52 ++++++++++++++
 rocketpy/utils.py                          | 52 --------------
 tests/unit/test_utilities.py               | 77 +++++++++++++++++++++
 tests/unit/test_utils.py                   | 80 ----------------------
 8 files changed, 141 insertions(+), 140 deletions(-)
 delete mode 100644 rocketpy/utils.py
 delete mode 100644 tests/unit/test_utils.py

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/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/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/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/unit/test_utilities.py b/tests/unit/test_utilities.py
index 8da56b646..d5b398097 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
+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(reset_rocketpy_logger):
+    """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(reset_rocketpy_logger):
+    """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(reset_rocketpy_logger):
+    """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(reset_rocketpy_logger):
+    """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(reset_rocketpy_logger):
+    """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(reset_rocketpy_logger, 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

From 7f11a31335a93e57ab1a2631079e0da55b07d645 Mon Sep 17 00:00:00 2001
From: Gui-FernandesBR 
Date: Thu, 9 Jul 2026 17:51:50 -0300
Subject: [PATCH 12/12] MNT: make logging-test fixture autouse to satisfy
 pylint

The reset_rocketpy_logger fixture was passed as an explicit test argument,
which pylint (W0613, unused-argument) flags. Restore the autouse pattern the
tests originally used in test_utils.py so the fixture runs without being a
parameter.

Co-Authored-By: Claude Opus 4.8 
---
 tests/unit/test_utilities.py | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py
index d5b398097..6b2eb7d5a 100644
--- a/tests/unit/test_utilities.py
+++ b/tests/unit/test_utilities.py
@@ -351,7 +351,7 @@ def test_load_from_rpy(mock_show):  # pylint: disable=unused-argument
 # --- Logging (rocketpy.utilities.enable_logging) ------------------------------
 
 
-@pytest.fixture
+@pytest.fixture(autouse=True)
 def reset_rocketpy_logger():
     """Reset the rocketpy logger to its original state after each test."""
     logger = logging.getLogger("rocketpy")
@@ -362,7 +362,7 @@ def reset_rocketpy_logger():
     logger.setLevel(original_level)
 
 
-def test_enable_logging_adds_stream_handler(reset_rocketpy_logger):
+def test_enable_logging_adds_stream_handler():
     """enable_logging() must attach a StreamHandler to the rocketpy logger."""
     utilities.enable_logging(level="INFO")
 
@@ -373,7 +373,7 @@ def test_enable_logging_adds_stream_handler(reset_rocketpy_logger):
     assert len(stream_handlers) >= 1
 
 
-def test_enable_logging_sets_correct_level(reset_rocketpy_logger):
+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
@@ -382,7 +382,7 @@ def test_enable_logging_sets_correct_level(reset_rocketpy_logger):
     assert logging.getLogger("rocketpy").level == logging.WARNING
 
 
-def test_enable_logging_no_duplicate_handlers(reset_rocketpy_logger):
+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")
@@ -394,7 +394,7 @@ def test_enable_logging_no_duplicate_handlers(reset_rocketpy_logger):
     assert len(stream_handlers) == 1
 
 
-def test_enable_logging_replaces_handler_on_level_change(reset_rocketpy_logger):
+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")
@@ -407,13 +407,13 @@ def test_enable_logging_replaces_handler_on_level_change(reset_rocketpy_logger):
     assert logger.level == logging.DEBUG
 
 
-def test_enable_logging_invalid_level_raises(reset_rocketpy_logger):
+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(reset_rocketpy_logger, caplog):
+def test_enable_logging_messages_are_captured(caplog):
     """After enable_logging(), internal rocketpy log messages must be visible."""
     utilities.enable_logging(level="DEBUG")