From 2d36cd77df1d2580d8f4f42ba20b5dc32bb15064 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 30 Mar 2026 09:56:42 -0400 Subject: [PATCH 01/17] ENH: added discrte and continuous controller functions --- rocketpy/rocket/rocket.py | 36 +++++++++++++++++++++++++++++++++++ rocketpy/simulation/flight.py | 3 +++ 2 files changed, 39 insertions(+) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 84655b6d7..a62298819 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2002,6 +2002,42 @@ def add_thrust_eccentricity(self, x, y): self.thrust_eccentricity_y = y return self + def add_discrete_controller(self, + controller_function, + refresh_rate, + interactive_objects=None, + initial_observed_variables=None, + name=None + ): + + controller = _Controller( + controller_function=controller_function, + sampling_rate=refresh_rate, + interactive_objects=interactive_objects, + initial_observed_variables=initial_observed_variables, + name=name) + + self._add_controllers(controller) + + return None + + def add_continuous_controller(self, + controller_function, + interactive_objects=None, + initial_observed_variables=None, + name=None + ): + + controller = _Controller( + controller_function=controller_function, + sampling_rate=np.inf, + interactive_objects=interactive_objects, + initial_observed_variables=initial_observed_variables, + name=name) + + self._add_controllers(controller) + return controller + def draw(self, vis_args=None, plane="xz", *, filename=None): """Draws the rocket in a matplotlib figure. diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 2293d9706..11ee00b3d 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -4488,6 +4488,9 @@ def add_parachutes(self, parachutes, t_init, t_end): def add_controllers(self, controllers, t_init, t_end): for controller in controllers: + # Skip node creation for continuous controllers + if math.isinf(controller.sampling_rate): + continue # Calculate start of sampling time nodes controller_time_step = 1 / controller.sampling_rate controller_node_list = [ From fa03cb8a401ca31872133f8e8eb2222c2bf47407 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 30 Mar 2026 10:19:35 -0400 Subject: [PATCH 02/17] linted rocketpy/rocket/rocket.py --- rocketpy/rocket/rocket.py | 42 +++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index a62298819..532fa0ae8 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2002,38 +2002,42 @@ def add_thrust_eccentricity(self, x, y): self.thrust_eccentricity_y = y return self - def add_discrete_controller(self, - controller_function, - refresh_rate, - interactive_objects=None, - initial_observed_variables=None, - name=None - ): + def add_discrete_controller( + self, + controller_function, + refresh_rate, + interactive_objects=None, + initial_observed_variables=None, + name=None, + ): controller = _Controller( - controller_function=controller_function, - sampling_rate=refresh_rate, - interactive_objects=interactive_objects, - initial_observed_variables=initial_observed_variables, - name=name) + controller_function=controller_function, + sampling_rate=refresh_rate, + interactive_objects=interactive_objects, + initial_observed_variables=initial_observed_variables, + name=name, + ) self._add_controllers(controller) return None - def add_continuous_controller(self, - controller_function, - interactive_objects=None, - initial_observed_variables=None, - name=None - ): + def add_continuous_controller( + self, + controller_function, + interactive_objects=None, + initial_observed_variables=None, + name=None, + ): controller = _Controller( controller_function=controller_function, sampling_rate=np.inf, interactive_objects=interactive_objects, initial_observed_variables=initial_observed_variables, - name=name) + name=name, + ) self._add_controllers(controller) return controller From 88c462f309013bf135ad024349bde226afd86c97 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 30 Mar 2026 10:23:51 -0400 Subject: [PATCH 03/17] lint fix return None to return self in discrete controller function --- rocketpy/rocket/rocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 532fa0ae8..4863d0396 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2021,7 +2021,7 @@ def add_discrete_controller( self._add_controllers(controller) - return None + return self def add_continuous_controller( self, From 38ea85493f41182030ddea3ca13fbad0954363eb Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 11 May 2026 16:28:57 -0400 Subject: [PATCH 04/17] BUG: fixed cont ctrl calls integrated into the ODE solver --- rocketpy/control/controller.py | 6 +++-- rocketpy/rocket/rocket.py | 46 ++++++++++++++++------------------ rocketpy/simulation/flight.py | 11 +++++++- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index e81e70915..45e32c815 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,3 +1,4 @@ +import math from inspect import signature from typing import Iterable @@ -17,7 +18,7 @@ def __init__( self, interactive_objects, controller_function, - sampling_rate, + sampling_rate=math.inf, initial_observed_variables=None, name="Controller", ): @@ -72,7 +73,8 @@ def __init__( relevant information in the `observed_variables` list. .. note:: The function will be called according to the sampling rate - specified. + specified. If unspecified, the default sampling rate is set to infinity, meaning that the + controller function will be called at every step of the simulation. sampling_rate : float The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 4863d0396..d59586e9e 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2002,42 +2002,38 @@ def add_thrust_eccentricity(self, x, y): self.thrust_eccentricity_y = y return self - def add_discrete_controller( - self, - controller_function, - refresh_rate, - interactive_objects=None, - initial_observed_variables=None, - name=None, - ): + def add_discrete_controller(self, + controller_function, + refresh_rate, + interactive_objects=None, + initial_observed_variables=None, + name=None + ): controller = _Controller( - controller_function=controller_function, - sampling_rate=refresh_rate, - interactive_objects=interactive_objects, - initial_observed_variables=initial_observed_variables, - name=name, - ) + controller_function=controller_function, + sampling_rate=refresh_rate, + interactive_objects=interactive_objects, + initial_observed_variables=initial_observed_variables, + name=name) self._add_controllers(controller) - return self + return None - def add_continuous_controller( - self, - controller_function, - interactive_objects=None, - initial_observed_variables=None, - name=None, - ): + def add_continuous_controller(self, + controller_function, + interactive_objects=None, + initial_observed_variables=None, + name=None + ): controller = _Controller( controller_function=controller_function, - sampling_rate=np.inf, + sampling_rate=math.inf, interactive_objects=interactive_objects, initial_observed_variables=initial_observed_variables, - name=name, - ) + name=name) self._add_controllers(controller) return controller diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 11ee00b3d..09aa0edaf 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -777,7 +777,14 @@ def __simulate(self, verbose): self.y_sol = phase.solver.y if verbose: print(f"Current Simulation Time: {self.t:3.4f} s", end="\r") - + for controller in self._continuous_controllers: + controller( + self.t, + self.y_sol, + self.solution[:-1], + self.sensors, + self.env, + ) if self.__check_simulation_events(phase, phase_index, node_index): break # Stop if simulation termination event occurred @@ -1576,6 +1583,8 @@ def __init_equations_of_motion(self): def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] + self._continuous_controllers = [c for c in self._controllers if math.isinf(c.sampling_rate)] + self._discrete_controllers = [c for c in self._controllers if not math.isinf(c.sampling_rate)] self.sensors = self.rocket.sensors.get_components() # reset controllable object to initial state (only airbrakes for now) From 1fc3f4216b956c23da10683a8f19ac3325d4bd6e Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 11 May 2026 16:40:57 -0400 Subject: [PATCH 05/17] lint fixes --- rocketpy/rocket/rocket.py | 42 +++++++++++++++++++---------------- rocketpy/simulation/flight.py | 8 +++++-- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index d59586e9e..e2641a717 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2002,38 +2002,42 @@ def add_thrust_eccentricity(self, x, y): self.thrust_eccentricity_y = y return self - def add_discrete_controller(self, - controller_function, - refresh_rate, - interactive_objects=None, - initial_observed_variables=None, - name=None - ): + def add_discrete_controller( + self, + controller_function, + refresh_rate, + interactive_objects=None, + initial_observed_variables=None, + name=None, + ): controller = _Controller( - controller_function=controller_function, - sampling_rate=refresh_rate, - interactive_objects=interactive_objects, - initial_observed_variables=initial_observed_variables, - name=name) + controller_function=controller_function, + sampling_rate=refresh_rate, + interactive_objects=interactive_objects, + initial_observed_variables=initial_observed_variables, + name=name, + ) self._add_controllers(controller) return None - def add_continuous_controller(self, - controller_function, - interactive_objects=None, - initial_observed_variables=None, - name=None - ): + def add_continuous_controller( + self, + controller_function, + interactive_objects=None, + initial_observed_variables=None, + name=None, + ): controller = _Controller( controller_function=controller_function, sampling_rate=math.inf, interactive_objects=interactive_objects, initial_observed_variables=initial_observed_variables, - name=name) + name=name, + ) self._add_controllers(controller) return controller diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 09aa0edaf..ea1338c42 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1583,8 +1583,12 @@ def __init_equations_of_motion(self): def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] - self._continuous_controllers = [c for c in self._controllers if math.isinf(c.sampling_rate)] - self._discrete_controllers = [c for c in self._controllers if not math.isinf(c.sampling_rate)] + self._continuous_controllers = [ + c for c in self._controllers if math.isinf(c.sampling_rate) + ] + self._discrete_controllers = [ + c for c in self._controllers if not math.isinf(c.sampling_rate) + ] self.sensors = self.rocket.sensors.get_components() # reset controllable object to initial state (only airbrakes for now) From 95e60ac4f2e4ba33414d902173a52b80cebb0877 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 11 May 2026 17:29:20 -0400 Subject: [PATCH 06/17] lint fix: removed return None --- rocketpy/rocket/rocket.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index e2641a717..51dd03876 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2021,8 +2021,6 @@ def add_discrete_controller( self._add_controllers(controller) - return None - def add_continuous_controller( self, controller_function, From 178876866392957db3e1de94a8249b79d830f4c5 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 11 May 2026 18:42:13 -0400 Subject: [PATCH 07/17] implemented copilot suggestions --- rocketpy/rocket/rocket.py | 10 ++++++++-- rocketpy/simulation/flight.py | 3 --- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 51dd03876..bb22bc231 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2008,8 +2008,11 @@ def add_discrete_controller( refresh_rate, interactive_objects=None, initial_observed_variables=None, - name=None, + name="Controller", ): + """Creates a new discrete controller, storing its parameters such as + controller function, refresh rate, and interactive objects. The controller + will be called at the specified refresh rate during the simulation.""" controller = _Controller( controller_function=controller_function, @@ -2026,8 +2029,11 @@ def add_continuous_controller( controller_function, interactive_objects=None, initial_observed_variables=None, - name=None, + name="Controller", ): + """Creates a new continuous controller, storing its parameters such as + controller function and interactive objects. The controller will + be called at every time step of the simulation.""" controller = _Controller( controller_function=controller_function, diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index ea1338c42..45e5189a4 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1586,9 +1586,6 @@ def __init_controllers(self): self._continuous_controllers = [ c for c in self._controllers if math.isinf(c.sampling_rate) ] - self._discrete_controllers = [ - c for c in self._controllers if not math.isinf(c.sampling_rate) - ] self.sensors = self.rocket.sensors.get_components() # reset controllable object to initial state (only airbrakes for now) From 554881a5470f2fb799ea64da0bdacd866fa65d63 Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Mon, 11 May 2026 18:45:21 -0400 Subject: [PATCH 08/17] lint fix, extra spaces --- rocketpy/rocket/rocket.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index bb22bc231..ce7df410a 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2011,7 +2011,7 @@ def add_discrete_controller( name="Controller", ): """Creates a new discrete controller, storing its parameters such as - controller function, refresh rate, and interactive objects. The controller + controller function, refresh rate, and interactive objects. The controller will be called at the specified refresh rate during the simulation.""" controller = _Controller( @@ -2032,7 +2032,7 @@ def add_continuous_controller( name="Controller", ): """Creates a new continuous controller, storing its parameters such as - controller function and interactive objects. The controller will + controller function and interactive objects. The controller will be called at every time step of the simulation.""" controller = _Controller( From 8ade87cf61ebc19fe7bdbb17d808fa6eb0d9769b Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Wed, 3 Jun 2026 18:35:55 -0400 Subject: [PATCH 09/17] ENH: merged dis+con and added None for sampling rate of cont --- rocketpy/control/controller.py | 7 +++--- rocketpy/rocket/rocket.py | 46 ++-------------------------------- rocketpy/simulation/flight.py | 4 +-- 3 files changed, 7 insertions(+), 50 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 45e32c815..18ec5ebfa 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,4 +1,3 @@ -import math from inspect import signature from typing import Iterable @@ -18,7 +17,7 @@ def __init__( self, interactive_objects, controller_function, - sampling_rate=math.inf, + sampling_rate=None, initial_observed_variables=None, name="Controller", ): @@ -73,8 +72,8 @@ def __init__( relevant information in the `observed_variables` list. .. note:: The function will be called according to the sampling rate - specified. If unspecified, the default sampling rate is set to infinity, meaning that the - controller function will be called at every step of the simulation. + specified. If unspecified, the default sampling rate is set to None, meaning that the + controller function will be called at every solver step of the simulation. sampling_rate : float The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index ce7df410a..4350ef175 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1169,6 +1169,8 @@ def _add_controllers(self, controllers): except TypeError: self._controllers.append(controllers) + return controllers + def add_tail( self, top_radius, bottom_radius, length, position, radius=None, name="Tail" ): @@ -2002,50 +2004,6 @@ def add_thrust_eccentricity(self, x, y): self.thrust_eccentricity_y = y return self - def add_discrete_controller( - self, - controller_function, - refresh_rate, - interactive_objects=None, - initial_observed_variables=None, - name="Controller", - ): - """Creates a new discrete controller, storing its parameters such as - controller function, refresh rate, and interactive objects. The controller - will be called at the specified refresh rate during the simulation.""" - - controller = _Controller( - controller_function=controller_function, - sampling_rate=refresh_rate, - interactive_objects=interactive_objects, - initial_observed_variables=initial_observed_variables, - name=name, - ) - - self._add_controllers(controller) - - def add_continuous_controller( - self, - controller_function, - interactive_objects=None, - initial_observed_variables=None, - name="Controller", - ): - """Creates a new continuous controller, storing its parameters such as - controller function and interactive objects. The controller will - be called at every time step of the simulation.""" - - controller = _Controller( - controller_function=controller_function, - sampling_rate=math.inf, - interactive_objects=interactive_objects, - initial_observed_variables=initial_observed_variables, - name=name, - ) - - self._add_controllers(controller) - return controller - def draw(self, vis_args=None, plane="xz", *, filename=None): """Draws the rocket in a matplotlib figure. diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 45e5189a4..324ad1eff 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1584,7 +1584,7 @@ def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] self._continuous_controllers = [ - c for c in self._controllers if math.isinf(c.sampling_rate) + c for c in self._controllers if c.sampling_rate is None ] self.sensors = self.rocket.sensors.get_components() @@ -4499,7 +4499,7 @@ def add_parachutes(self, parachutes, t_init, t_end): def add_controllers(self, controllers, t_init, t_end): for controller in controllers: # Skip node creation for continuous controllers - if math.isinf(controller.sampling_rate): + if controller.sampling_rate is None: continue # Calculate start of sampling time nodes controller_time_step = 1 / controller.sampling_rate From 85e38b90e8a2000d06db4954af3dc8a99af4d9ea Mon Sep 17 00:00:00 2001 From: Malmahrouqi3 Date: Wed, 3 Jun 2026 21:27:47 -0400 Subject: [PATCH 10/17] BUG: copilot comments addressed --- rocketpy/rocket/rocket.py | 2 -- rocketpy/simulation/flight.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 4350ef175..84655b6d7 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1169,8 +1169,6 @@ def _add_controllers(self, controllers): except TypeError: self._controllers.append(controllers) - return controllers - def add_tail( self, top_radius, bottom_radius, length, position, radius=None, name="Tail" ): diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 324ad1eff..a884d3fe4 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -781,7 +781,7 @@ def __simulate(self, verbose): controller( self.t, self.y_sol, - self.solution[:-1], + [step[1:] for step in self.solution[:-1]], self.sensors, self.env, ) From bb90cddc7923693a88ac81346410f3dc10243a9b Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Fri, 19 Jun 2026 00:46:26 -0300 Subject: [PATCH 11/17] ENH: finalize continuous controller handling Optimize continuous controller state-history handling to avoid repeated list rebuilding, align controller sampling-rate docs with None-based continuous mode, and add regression coverage to ensure continuous controllers do not create time nodes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rocketpy/control/controller.py | 11 ++++---- rocketpy/simulation/flight.py | 19 +++++++------ .../unit/simulation/test_flight_time_nodes.py | 28 ++++++++++++++++++- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 18ec5ebfa..286726496 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -71,13 +71,14 @@ def __init__( objects as needed. The function return statement can be used to save relevant information in the `observed_variables` list. - .. note:: The function will be called according to the sampling rate - specified. If unspecified, the default sampling rate is set to None, meaning that the - controller function will be called at every solver step of the simulation. - sampling_rate : float + .. note:: The function will be called according to the sampling + rate specified. If `sampling_rate` is None, the controller + function is called at every solver step of the simulation. + sampling_rate : float, optional The sampling rate of the controller function in Hertz (Hz). This means that the controller function will be called every - `1/sampling_rate` seconds. + `1/sampling_rate` seconds. If None, it is treated as a + continuous controller and called at every solver step. initial_observed_variables : list, optional A list of the initial values of the variables that the controller function returns. This list is used to initialize the diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a884d3fe4..342282061 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -777,14 +777,16 @@ def __simulate(self, verbose): self.y_sol = phase.solver.y if verbose: print(f"Current Simulation Time: {self.t:3.4f} s", end="\r") - for controller in self._continuous_controllers: - controller( - self.t, - self.y_sol, - [step[1:] for step in self.solution[:-1]], - self.sensors, - self.env, - ) + if self._continuous_controllers: + for controller in self._continuous_controllers: + controller( + self.t, + self.y_sol, + self._controller_state_history, + self.sensors, + self.env, + ) + self._controller_state_history.append(list(self.y_sol)) if self.__check_simulation_events(phase, phase_index, node_index): break # Stop if simulation termination event occurred @@ -1544,6 +1546,7 @@ def __init_solver_monitors(self): self.t_initial = self.initial_solution[0] self.solution.append(self.initial_solution) + self._controller_state_history = [self.initial_solution[1:]] self.t = self.solution[-1][0] self.y_sol = self.solution[-1][1:] diff --git a/tests/unit/simulation/test_flight_time_nodes.py b/tests/unit/simulation/test_flight_time_nodes.py index 20769b1f8..2c330d30a 100644 --- a/tests/unit/simulation/test_flight_time_nodes.py +++ b/tests/unit/simulation/test_flight_time_nodes.py @@ -2,7 +2,7 @@ TimeNode. """ -# from rocketpy.rocket import Parachute, _Controller +from rocketpy.control.controller import _Controller def test_time_nodes_init(flight_calisto): @@ -49,6 +49,32 @@ def test_time_nodes_add_node(flight_calisto): # TODO: implement this test +def test_time_nodes_add_controllers_skips_continuous_controllers(flight_calisto): + """Ensure only discrete controllers create time nodes.""" + # Arrange + discrete_controller = _Controller( + interactive_objects=[], + controller_function=lambda t, sr, sv, sh, ov, io: None, + sampling_rate=10, + name="Discrete", + ) + continuous_controller = _Controller( + interactive_objects=[], + controller_function=lambda t, sr, sv, sh, ov, io: None, + sampling_rate=None, + name="Continuous", + ) + time_nodes = flight_calisto.TimeNodes() + + # Act + time_nodes.add_controllers([discrete_controller, continuous_controller], 0, 1) + + # Assert + assert len(time_nodes) == 11 + assert all(node._controllers == [discrete_controller] for node in time_nodes) + assert all(continuous_controller not in node._controllers for node in time_nodes) + + def test_time_nodes_sort(flight_calisto): time_nodes = flight_calisto.TimeNodes() time_nodes.add_node(3.0, [], [], []) From 079385a1f2f32427d62e0bae633feabcc83fc9c0 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Fri, 19 Jun 2026 01:00:28 -0300 Subject: [PATCH 12/17] DOC: update changelog for PR 946 Add unreleased changelog entry for Discrete and Continuous Controllers in PR #946. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2183005f8..4a2a8191f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Attention: The newest changes should be on top --> - ENH: Monte Carlo Formatting Options [#947](https://github.com/RocketPy-Team/RocketPy/pull/947) - ENH: ENH: Auto-Detection of Pressure Conversion Factor [#966](https://github.com/RocketPy-Team/RocketPy/pull/966) - ENH: Auto-Detection of Pressure Conversion Factor [#966](https://github.com/RocketPy-Team/RocketPy/pull/966) +- ENH: Discrete and Continuous Controllers [#946](https://github.com/RocketPy-Team/RocketPy/pull/946) - ENH: MNT: introduce pressure unit conversion when using forecast/reanalysis/ensemble data [#955](https://github.com/RocketPy-Team/RocketPy/pull/955) - ENH: Auto Populate Changelog [#919](https://github.com/RocketPy-Team/RocketPy/pull/919) - ENH: Adaptive Monte Carlo via Convergence Criteria [#922](https://github.com/RocketPy-Team/RocketPy/pull/922) From f67feee302c705c62c41d23ac98fbd1c0676e7b4 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 08:58:32 -0300 Subject: [PATCH 13/17] BUG: pass self.solution to continuous controllers for parity with discrete The continuous-controller path fed the controller a time-stripped `_controller_state_history` (rows `[x, y, z, vx, vy, vz, ...]`), while the discrete path (__process_sensors_and_controllers_at_current_node) passes `self.solution`, whose rows are time-prefixed (`[t, x, y, z, vx, vy, vz, ...]`). A single controller function therefore received two different state_history layouts depending on discrete vs continuous mode: index [5] was vy for discrete but vz for continuous. Controllers written/tuned against the discrete layout (e.g. the airbrakes example) misbehaved under continuous control -- reading vz where they expected vy, deployment logic exploded and apogee was crushed. Pass `self.solution` directly, matching the discrete path exactly. This makes the two paths behaviorally identical, removes the parallel _controller_state_history list (and its per-step append / initialization), and is O(1) per step. Co-Authored-By: Claude Opus 4.8 (1M context) --- rocketpy/simulation/flight.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 2f214de7d..c546ae53f 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -774,16 +774,14 @@ def __simulate(self, verbose): print(f"Current Simulation Time: {self.t:3.4f} s", end="\r") logger.debug("Current Simulation Time: %3.4f s", self.t) - if self._continuous_controllers: - for controller in self._continuous_controllers: - controller( - self.t, - self.y_sol, - self._controller_state_history, - self.sensors, - self.env, - ) - self._controller_state_history.append(list(self.y_sol)) + for controller in self._continuous_controllers: + controller( + self.t, + self.y_sol, + self.solution, + self.sensors, + self.env, + ) if self.__check_simulation_events(phase, phase_index, node_index): break # Stop if simulation termination event occurred @@ -1556,7 +1554,6 @@ def __init_solver_monitors(self): self.t_initial = self.initial_solution[0] self.solution.append(self.initial_solution) - self._controller_state_history = [self.initial_solution[1:]] self.t = self.solution[-1][0] self.y_sol = self.solution[-1][1:] From dae3e288d3e86d7adce77d0815d636155c508478 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 09:10:24 -0300 Subject: [PATCH 14/17] MNT: add _Controller.is_continuous and fix prints for continuous controllers - Add a single `_Controller.is_continuous` property (sampling_rate is None) and use it as the one source of truth for the continuous/discrete dispatch in Flight (__init_controllers and time-node creation), replacing two duplicated `sampling_rate is None` checks. - Fix _ControllerPrints.controller_function(): `f"{sampling_rate:.3f}"` raised TypeError for continuous controllers (sampling_rate=None). Print "continuous (every solver step)" instead. - __str__ now reads "with continuous sampling." instead of "... None Hz." Co-Authored-By: Claude Opus 4.8 (1M context) --- rocketpy/control/controller.py | 8 ++++++++ rocketpy/prints/controller_prints.py | 5 ++++- rocketpy/simulation/flight.py | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 286726496..fb85ad3b0 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -210,7 +210,15 @@ def __call__(self, time, state_vector, state_history, sensors, environment): if observed_variables is not None: self.observed_variables.append(observed_variables) + @property + def is_continuous(self): + """bool: True if the controller runs at every solver step (i.e. + ``sampling_rate`` is None), False if it is sampled at a fixed rate.""" + return self.sampling_rate is None + def __str__(self): + if self.is_continuous: + return f"Controller '{self.name}' with continuous sampling." return f"Controller '{self.name}' with sampling rate {self.sampling_rate} Hz." def info(self): diff --git a/rocketpy/prints/controller_prints.py b/rocketpy/prints/controller_prints.py index cb19ec00c..e2690c36a 100644 --- a/rocketpy/prints/controller_prints.py +++ b/rocketpy/prints/controller_prints.py @@ -41,7 +41,10 @@ def controller_function(self): print( "Controller function: " + self.controller.controller_function.__name__ ) - print(f"Controller refresh rate: {self.controller.sampling_rate:.3f} Hz") + if self.controller.is_continuous: + print("Controller refresh rate: continuous (every solver step)") + else: + print(f"Controller refresh rate: {self.controller.sampling_rate:.3f} Hz") def interactive_objects(self): """Prints interactive objects.""" diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index c546ae53f..edad4e1cb 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1594,7 +1594,7 @@ def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] self._continuous_controllers = [ - c for c in self._controllers if c.sampling_rate is None + c for c in self._controllers if c.is_continuous ] self.sensors = self.rocket.sensors.get_components() @@ -4480,7 +4480,7 @@ def add_parachutes(self, parachutes, t_init, t_end): def add_controllers(self, controllers, t_init, t_end): for controller in controllers: # Skip node creation for continuous controllers - if controller.sampling_rate is None: + if controller.is_continuous: continue # Calculate start of sampling time nodes controller_time_step = 1 / controller.sampling_rate From c772980e3d9594843398bc29319888c63b761848 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 09:19:10 -0300 Subject: [PATCH 15/17] DOC: correct state_history contract for controller functions The state_history handed to controller functions is `Flight.solution`, whose rows are TIME-PREFIXED `[t, x, y, z, vx, vy, vz, ...]` -- one leading time element ahead of the `state` layout, and the last row is the most recent recorded step (not the "previous" state). The docstrings previously described the rows as bare state vectors `[x, y, z, ...]` with `[-1]` as the previous state, an off-by-one-column contract that silently breaks controllers (this is what caused the airbrakes continuous-mode misbehavior). Also document that `sampling_rate` is None for continuous controllers. Updated in _Controller.__init__, _Controller.__call__, and Rocket.add_air_brakes controller docstrings. Co-Authored-By: Claude Opus 4.8 (1M context) --- rocketpy/control/controller.py | 27 ++++++++++++++++----------- rocketpy/rocket/rocket.py | 16 ++++++++++------ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index fb85ad3b0..266389fa3 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -38,16 +38,19 @@ def __init__( This function is expected to take the following arguments, in order: 1. `time` (float): The current simulation time in seconds. - 2. `sampling_rate` (float): The rate at which the controller - function is called, measured in Hertz (Hz). + 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 (called every solver step), so any + `1 / sampling_rate` computation must guard against None. 3. `state` (list): The state vector of the simulation, structured as `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. 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. + step throughout the simulation. It is organized as a list of + lists, ordered oldest to newest, where each sublist is a + *time-prefixed* state row `[t, x, y, z, vx, vy, vz, e0, e1, e2, + e3, wx, wy, wz]` (i.e. the same layout as `Flight.solution`, one + leading `time` element ahead of the `state` layout in item 3). + The last item corresponds to the most recent recorded step. 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 @@ -180,10 +183,12 @@ def __call__(self, time, state_vector, state_history, sensors, environment): `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. state_history : list - A list containing the state history of the simulation. The state - history is a list of every state vector of every step of the - simulation. The state history is a list of lists, where each - sublist is a state vector and is ordered from oldest to newest. + A list containing the state history of the simulation, ordered + oldest to newest. Each sublist is a *time-prefixed* state row + `[t, x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]` (the same + layout as `Flight.solution`, with a leading `time` element ahead of + the `state_vector` layout). The last item is the most recent + recorded step. sensors : list A list of sensors that are attached to the rocket. The most recent measurements of the sensors are provided with the diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 4a72778e8..dce52b37c 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1826,15 +1826,19 @@ def add_air_brakes( This function is expected to take the following arguments, in order: 1. `time` (float): The current simulation time in seconds. - 2. `sampling_rate` (float): The rate at which the controller - function is called, measured in Hertz (Hz). + 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 (called every solver step), so any + `1 / sampling_rate` computation must guard against None. 3. `state` (list): The state vector of the simulation, structured as `[x, y, z, vx, vy, vz, e0, e1, e2, e3, wx, wy, wz]`. 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. + step throughout the simulation. It is organized as a list of + lists, ordered oldest to newest, where each sublist is a + *time-prefixed* state row `[t, x, y, z, vx, vy, vz, e0, e1, e2, + e3, wx, wy, wz]` (the same layout as `Flight.solution`, one + leading `time` element ahead of the `state` layout in item 3). + The last item corresponds to the most recent recorded step. 5. `observed_variables` (list): A list containing the variables that the controller function returns. The initial value in the first step of the simulation of this list is provided by the From 3b2460c01332a9a4bf0b412295ca849e66a3ad60 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 09:21:26 -0300 Subject: [PATCH 16/17] TST: cover continuous controller per-step invocation and state_history layout Adds an integration test that runs a real flight with a continuous air-brakes controller (sampling_rate=None) and asserts it is invoked on every solver step (>50 calls), always receives sampling_rate=None, and receives time-prefixed state_history rows (len == len(state)+1) -- the same layout as the discrete path. Previously only node-skipping was covered; per-step invocation was not. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/simulation/test_flight.py | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index 46cee65e7..e3336bb67 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -873,3 +873,47 @@ def test_environment_methods_accessible_in_controller( # Verify all environment methods were successfully called assert all(methods_called.values()), f"Not all methods called: {methods_called}" + + +def test_continuous_controller_invoked_every_step(calisto_robust, example_plain_env): + """A continuous controller (sampling_rate=None) must be called on every + solver step and receive the same state_history layout as a discrete one: + time-prefixed rows (`[t, *state]`), one element longer than ``state``. + This locks in the discrete/continuous parity contract.""" + calls = {"count": 0, "sampling_rates": set(), "row_len_matches": True} + + def recording_controller( # pylint: disable=unused-argument + time, sampling_rate, state, state_history, observed_variables, air_brakes + ): + calls["count"] += 1 + calls["sampling_rates"].add(sampling_rate) + # state_history rows are time-prefixed: exactly one longer than state + if len(state_history[-1]) != len(state) + 1: + calls["row_len_matches"] = False + return None + + calisto_robust.parachutes = [] + calisto_robust.add_air_brakes( + drag_coefficient_curve="data/rockets/calisto/air_brakes_cd.csv", + controller_function=recording_controller, + sampling_rate=None, # continuous + clamp=True, + ) + + flight = Flight( + rocket=calisto_robust, + environment=example_plain_env, + rail_length=5.2, + inclination=85, + heading=0, + time_overshoot=False, + terminate_on_apogee=True, + ) + + assert flight.t_final > 0 + # Called many times (once per solver step), far more than any fixed rate + assert calls["count"] > 50 + # The controller always saw sampling_rate=None (continuous) + assert calls["sampling_rates"] == {None} + # And time-prefixed rows, consistent with the discrete controller path + assert calls["row_len_matches"] From 88ef28dff318c562eccdb79b2a320e728c09b586 Mon Sep 17 00:00:00 2001 From: Gui-FernandesBR Date: Sat, 4 Jul 2026 09:46:26 -0300 Subject: [PATCH 17/17] MNT: satisfy ruff format and pylint in CI lint job - flight.py: collapse the _continuous_controllers comprehension to one line (ruff format --check). - test_flight.py: drop the useless `return None` at the end of the recording controller (pylint R1711). Co-Authored-By: Claude Opus 4.8 (1M context) --- rocketpy/simulation/flight.py | 4 +--- tests/integration/simulation/test_flight.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index edad4e1cb..13e0120f3 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1593,9 +1593,7 @@ def __init_equations_of_motion(self): def __init_controllers(self): """Initialize controllers and sensors""" self._controllers = self.rocket._controllers[:] - self._continuous_controllers = [ - c for c in self._controllers if c.is_continuous - ] + self._continuous_controllers = [c for c in self._controllers if c.is_continuous] self.sensors = self.rocket.sensors.get_components() # reset controllable object to initial state (only airbrakes for now) diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index e3336bb67..7e00a341b 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -890,7 +890,6 @@ def recording_controller( # pylint: disable=unused-argument # state_history rows are time-prefixed: exactly one longer than state if len(state_history[-1]) != len(state) + 1: calls["row_len_matches"] = False - return None calisto_robust.parachutes = [] calisto_robust.add_air_brakes(