Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2d36cd7
ENH: added discrte and continuous controller functions
Malmahrouqi3 Mar 30, 2026
fa03cb8
linted rocketpy/rocket/rocket.py
Malmahrouqi3 Mar 30, 2026
88c462f
lint fix return None to return self in discrete controller function
Malmahrouqi3 Mar 30, 2026
38ea854
BUG: fixed cont ctrl calls integrated into the ODE solver
Malmahrouqi3 May 11, 2026
1fc3f42
lint fixes
Malmahrouqi3 May 11, 2026
95e60ac
lint fix: removed return None
Malmahrouqi3 May 11, 2026
1788768
implemented copilot suggestions
Malmahrouqi3 May 11, 2026
554881a
lint fix, extra spaces
Malmahrouqi3 May 11, 2026
8ade87c
ENH: merged dis+con and added None for sampling rate of cont
Malmahrouqi3 Jun 3, 2026
85e38b9
BUG: copilot comments addressed
Malmahrouqi3 Jun 4, 2026
bb90cdd
ENH: finalize continuous controller handling
Gui-FernandesBR Jun 19, 2026
079385a
DOC: update changelog for PR 946
Gui-FernandesBR Jun 19, 2026
0aa94b8
Merge branch 'develop' into enh/discrete_and_contrinuous_control
Gui-FernandesBR Jul 4, 2026
f67feee
BUG: pass self.solution to continuous controllers for parity with dis…
Gui-FernandesBR Jul 4, 2026
dae3e28
MNT: add _Controller.is_continuous and fix prints for continuous cont…
Gui-FernandesBR Jul 4, 2026
c772980
DOC: correct state_history contract for controller functions
Gui-FernandesBR Jul 4, 2026
3b2460c
TST: cover continuous controller per-step invocation and state_histor…
Gui-FernandesBR Jul 4, 2026
88ef28d
MNT: satisfy ruff format and pylint in CI lint job
Gui-FernandesBR Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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)
- DOC: Add aerodynamic surfaces user guide [#1043](https://github.com/RocketPy-Team/RocketPy/pull/1043)
- ENH: Add RingClusterMotor for annular clustered motor modeling [#924](https://github.com/RocketPy-Team/RocketPy/pull/924)
- ENH: MNT: introduce pressure unit conversion when using forecast/reanalysis/ensemble data [#955](https://github.com/RocketPy-Team/RocketPy/pull/955)
Expand Down
47 changes: 31 additions & 16 deletions rocketpy/control/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def __init__(
self,
interactive_objects,
controller_function,
sampling_rate,
sampling_rate=None,
initial_observed_variables=None,
name="Controller",
):
Expand All @@ -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
Expand All @@ -71,12 +74,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.
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
Expand Down Expand Up @@ -178,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
Expand All @@ -208,7 +215,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):
Expand Down
5 changes: 4 additions & 1 deletion rocketpy/prints/controller_prints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
16 changes: 10 additions & 6 deletions rocketpy/rocket/rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions rocketpy/simulation/flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +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)

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

Expand Down Expand Up @@ -1585,6 +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.sensors = self.rocket.sensors.get_components()

# reset controllable object to initial state (only airbrakes for now)
Expand Down Expand Up @@ -4468,6 +4477,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 controller.is_continuous:
continue
# Calculate start of sampling time nodes
controller_time_step = 1 / controller.sampling_rate
controller_node_list = [
Expand Down
43 changes: 43 additions & 0 deletions tests/integration/simulation/test_flight.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,3 +873,46 @@ 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

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"]
28 changes: 27 additions & 1 deletion tests/unit/simulation/test_flight_time_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
TimeNode.
"""

# from rocketpy.rocket import Parachute, _Controller
from rocketpy.control.controller import _Controller


def test_time_nodes_init(flight_calisto):
Expand Down Expand Up @@ -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, [], [], [])
Expand Down
Loading