diff --git a/CHANGELOG.md b/CHANGELOG.md index c492de31d..36917cb48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index e81e70915..266389fa3 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -17,7 +17,7 @@ def __init__( self, interactive_objects, controller_function, - sampling_rate, + sampling_rate=None, initial_observed_variables=None, name="Controller", ): @@ -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 @@ -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 @@ -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 @@ -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): 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/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 diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index caa8489ec..13e0120f3 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -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 @@ -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) @@ -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 = [ diff --git a/tests/integration/simulation/test_flight.py b/tests/integration/simulation/test_flight.py index 46cee65e7..7e00a341b 100644 --- a/tests/integration/simulation/test_flight.py +++ b/tests/integration/simulation/test_flight.py @@ -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"] 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, [], [], [])