Skip to content

ENH: Discrete and Continuous Controllers#946

Merged
Gui-FernandesBR merged 18 commits into
RocketPy-Team:developfrom
Malmahrouqi3:enh/discrete_and_contrinuous_control
Jul 4, 2026
Merged

ENH: Discrete and Continuous Controllers#946
Gui-FernandesBR merged 18 commits into
RocketPy-Team:developfrom
Malmahrouqi3:enh/discrete_and_contrinuous_control

Conversation

@Malmahrouqi3

@Malmahrouqi3 Malmahrouqi3 commented Mar 30, 2026

Copy link
Copy Markdown
Member

Pull request type

  • Code changes (bugfix, features)

Checklist

  • Tests for the changes have been added (if needed)
  • Lint (black rocketpy/ tests/) has passed locally

Current behavior

#274

New behavior

Usage

airbrake_controller = _Controller(
    controller_function=controller_function,
    sampling_rate=10, # raplace with None or remove for continuous control
    interactive_objects=air_brakes,
    initial_observed_variables=[0, 0, 0],
    name="Discrete Air Brakes Controller (10 Hz)",
)
calisto._add_controllers(airbrake_controller)
calisto.air_brakes.append(air_brakes)

Tested on the Airbrakes Example (docs/user/airbrakes.rst)

(Discrete 10 Hz sampling)
195 Calls
Apogee State
Apogee Time: 23.360 s
Apogee Altitude: 4400.391 m (ASL) | 3000.391 m (AGL)
Apogee Freestream Speed: 16.149 m/s
Apogee X position: -4.510 m
Apogee Y position: 488.084 m
Apogee latitude: 32.9946429°
Apogee longitude: -106.9750464°

Maximum altitude: 3000.39 m
image
(Discrete 100 Hz sampling)
1947 Calls
Apogee State
Apogee Time: 23.362 s
Apogee Altitude: 4400.620 m (ASL) | 3000.620 m (AGL)
Apogee Freestream Speed: 16.151 m/s
Apogee X position: -4.511 m
Apogee Y position: 488.145 m
Apogee latitude: 32.9946434°
Apogee longitude: -106.9750464°

Maximum altitude: 3000.62 m
image
(Discrete 1000 Hz sampling)
19462 Calls
Apogee State
Apogee Time: 23.362 s
Apogee Altitude: 4400.594 m (ASL) | 3000.594 m (AGL)
Apogee Freestream Speed: 16.150 m/s
Apogee X position: -4.511 m
Apogee Y position: 488.136 m
Apogee latitude: 32.9946433°
Apogee longitude: -106.9750464°

Maximum altitude: 2903.45 m
image
(Continuous inf sampling)
410 Calls

Apogee State
Apogee Time: 23.369 s
Apogee Altitude: 4401.779 m (ASL) | 3001.779 m (AGL)
Apogee Freestream Speed: 16.157 m/s
Apogee X position: -4.104 m
Apogee Y position: 488.409 m
Apogee latitude: 32.9946458°
Apogee longitude: -106.9750420°

Maximum altitude: 3001.78 m
image
(No Controller)
0 Calls
Apogee State
Apogee Time: 25.886 s
Apogee Altitude: 4705.474 m (ASL) | 3305.474 m (AGL)
Apogee Freestream Speed: 20.662 m/s
Apogee X position: -5.543 m
Apogee Y position: 588.779 m
Apogee latitude: 32.9955483°
Apogee longitude: -106.9750574°

Maximum altitude: 3305.47 m

Breaking change

  • Yes
  • No

Additional information

There is no node creation for continuous controllers hence this step is skipped in flight.py. Instead, they are called in each of the ODE solver step, hence not relying on fixed sampling rate.

@Malmahrouqi3 Malmahrouqi3 self-assigned this Mar 30, 2026
@codecov

codecov Bot commented Mar 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.53846% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.41%. Comparing base (9cf3dd4) to head (88ef28d).
⚠️ Report is 98 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/prints/controller_prints.py 0.00% 3 Missing ⚠️
rocketpy/control/controller.py 60.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #946      +/-   ##
===========================================
+ Coverage    80.27%   81.41%   +1.14%     
===========================================
  Files          104      118      +14     
  Lines        12769    15172    +2403     
===========================================
+ Hits         10250    12352    +2102     
- Misses        2519     2820     +301     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Malmahrouqi3 Malmahrouqi3 force-pushed the enh/discrete_and_contrinuous_control branch from 538aca0 to a4de476 Compare May 11, 2026 21:23
@Malmahrouqi3 Malmahrouqi3 marked this pull request as ready for review May 11, 2026 21:47
@Malmahrouqi3 Malmahrouqi3 requested a review from a team as a code owner May 11, 2026 21:47
@Malmahrouqi3 Malmahrouqi3 requested a review from Copilot May 11, 2026 21:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class support for discrete (fixed refresh rate) and continuous (called every solver step) controllers to RocketPy’s flight simulation, aligning with the controller architecture discussed in issue #274.

Changes:

  • Invoke continuous controllers during the solver stepping loop (instead of via fixed time nodes).
  • Add Rocket.add_discrete_controller and Rocket.add_continuous_controller helpers that build _Controller objects with finite vs inf sampling rates.
  • Update _Controller to default sampling_rate to math.inf and document the “infinite sampling” behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 8 comments.

File Description
rocketpy/simulation/flight.py Calls continuous controllers each solver step and skips time-node creation for sampling_rate=inf.
rocketpy/rocket/rocket.py Adds Rocket-level APIs to register discrete vs continuous controllers.
rocketpy/control/controller.py Defaults controller sampling rate to infinity and updates documentation accordingly.
Comments suppressed due to low confidence (1)

rocketpy/control/controller.py:81

  • The added note in the docstring exceeds the typical line-length used elsewhere in the project (and likely will be reformatted by Ruff/Black). Please wrap this note to keep docstring formatting consistent/readable, and consider clarifying how sampling_rate=inf interacts with the existing 1/sampling_rate wording in the sampling_rate parameter description.
            .. 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.
        sampling_rate : float
            The sampling rate of the controller function in Hertz (Hz). This
            means that the controller function will be called every
            `1/sampling_rate` seconds.

Comment thread rocketpy/simulation/flight.py
Comment thread rocketpy/simulation/flight.py
Comment thread rocketpy/simulation/flight.py Outdated
Comment thread rocketpy/simulation/flight.py
Comment thread rocketpy/rocket/rocket.py Outdated
Comment thread rocketpy/rocket/rocket.py Outdated
Comment thread rocketpy/rocket/rocket.py Outdated
Comment thread rocketpy/rocket/rocket.py Outdated

@MateusStano MateusStano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really clean! Just requested a couple of changes and it should be good to merge

Comment thread rocketpy/rocket/rocket.py Outdated
Comment thread rocketpy/control/controller.py Outdated
@Malmahrouqi3

Copy link
Copy Markdown
Member Author

Really clean! Just requested a couple of changes and it should be good to merge

Added the requested changes and ready to merge.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread rocketpy/simulation/flight.py
Comment thread rocketpy/control/controller.py Outdated
Comment thread rocketpy/rocket/rocket.py Outdated
@Gui-FernandesBR

Copy link
Copy Markdown
Member

Addressed the requested points and pushed follow-up commit 601acb0:\n\n- kept continuous controllers represented by sampling_rate=None\n- ensured continuous controllers do not create time nodes (covered by unit test)\n- updated controller docstring to document sampling_rate as optional/continuous semantics\n- optimized continuous-controller history handling in Flight to avoid rebuilding state history on every step while preserving the documented state-vector format\n\nLocal checks run:\n- pytest tests/unit/simulation/test_flight_time_nodes.py -q\n- pytest tests/unit/simulation/test_flight.py -q\n-
uff check rocketpy/ tests/ docs/\n\nIssue context: this PR targets #274 (Continuous and Discrete Control).\n\n@MateusStano could you please re-review?

@Gui-FernandesBR Gui-FernandesBR left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the latest commit 601acb0: state-history handling is now efficient and consistent, continuous-controller node scheduling is covered by unit tests, and controller docs reflect optional sampling_rate behavior.

@Gui-FernandesBR Gui-FernandesBR force-pushed the enh/discrete_and_contrinuous_control branch from 601acb0 to bb90cdd Compare June 19, 2026 03:59
@Gui-FernandesBR Gui-FernandesBR requested a review from Copilot June 19, 2026 03:59
Add unreleased changelog entry for Discrete and Continuous Controllers in PR RocketPy-Team#946.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread rocketpy/simulation/flight.py Outdated
Comment thread rocketpy/simulation/flight.py Outdated
@Malmahrouqi3

Copy link
Copy Markdown
Member Author
image

@Gui-FernandesBR
I took one of your recent commits and the behavior was odd. My last push was finalized with the intent to merge the PR.
If you wish to make any changes, please use the notebook to make sure everything is alright!
airbrakes3.ipynb

@Gui-FernandesBR Gui-FernandesBR linked an issue Jun 19, 2026 that may be closed by this pull request
Gui-FernandesBR and others added 5 commits July 4, 2026 08:57
Resolve conflicts in:
- rocketpy/simulation/flight.py: keep develop's logger.debug migration in the
  verbose block and re-apply the continuous-controller invocation alongside it.
- CHANGELOG.md: keep the RocketPy-Team#946 entry plus develop's RocketPy-Team#1043 and RocketPy-Team#924 entries.
…crete

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) <noreply@anthropic.com>
…rollers

- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…y 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) <noreply@anthropic.com>
Gui-FernandesBR added a commit that referenced this pull request Jul 4, 2026
Resolve flight.py (keep develop logger.debug + continuous-controller loop) and re-add the #946 CHANGELOG entry.
- 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) <noreply@anthropic.com>
@Gui-FernandesBR

Copy link
Copy Markdown
Member

Hi @Malmahrouqi3 — thanks for flagging the odd behavior and for attaching airbrakes3.ipynb, that made it fully reproducible. I found the root cause and pushed the fix on top of your commits as a fast-forward (no force-push), so your history is preserved.

Root cause: discrete and continuous handed the controller different state_history layouts

  • 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, ...].
  • The continuous path passed a time-stripped history: [x, y, z, vx, vy, vz, ...].

So state_history[-1][5] resolved to vy for discrete but vz for continuous. Our airbrakes controller reads previous_vz = state_history[-1][5] and uses 0.01 * previous_vz**2. In continuous mode it was therefore squaring the vertical velocity (~280 m/s) instead of the small lateral component — so previous_vz**2 blew up (~80,000 vs ~950), the airbrakes slammed fully open, and apogee collapsed to ~1370 m AGL instead of ~3000 m. I reproduced this exactly with your notebook.

Worth stressing: this layout mismatch pre-dates the recent commits — the original continuous implementation stripped the time column too ([step[1:] for step in self.solution[:-1]]). It only became visible once the controller leaned on state_history for velocity.

Fix

Pass self.solution to continuous controllers, identical to the discrete path. Both paths now hand over the same time-prefixed layout — verified: state_history[-1] row length is 14 in both modes, and state_history[-1][1:] == state in 100% of calls. A single controller function now behaves the same whether discrete or continuous. This also drops the parallel _controller_state_history list (O(1) per step).

Also included in this push

  • Merged develop and resolved the conflicts — the PR is mergeable again.
  • Fixed a TypeError in controller.info() when sampling_rate=None (f"{None:.3f}"), via a new _Controller.is_continuous property used as the single source of truth for the continuous/discrete dispatch.
  • Corrected the state_history docstrings (_Controller, Rocket.add_air_brakes) — they described time-stripped rows, which is exactly what led to the confusion. They now document the time-prefixed layout and that sampling_rate is None for continuous controllers.
  • Added an integration test asserting continuous controllers are invoked every solver step, always receive sampling_rate=None, and receive the time-prefixed layout.
  • CI is green (lint + pytest).

Everything is on top of your work — happy to adjust anything. Let me know what you think!

@Gui-FernandesBR Gui-FernandesBR merged commit f5028d0 into RocketPy-Team:develop Jul 4, 2026
9 of 10 checks passed
Gui-FernandesBR added a commit that referenced this pull request Jul 4, 2026
* MNT: final fixes before next release

Documentation, CHANGELOG and test polish ahead of the next release
(v1.13.0), covering PRs merged since v1.12.0.

CHANGELOG
- Add missing entries: Individual Fins (#818), AIGFS/HRRR forecast
  models (#951), duplicate-controller fix (#949), Valkyrie flight
  example (#967).
- Hygiene: de-duplicate #958/#966, move #974 to Fixed and #1041 to
  Removed only, drop the already-released #914 duplicate, and point the
  logging (#973) and ND-interp (#969) entries at their PRs.
- Backfill #940/#941/#944 (shipped in v1.12.0 code but never logged)
  under the [v1.12.0] section.

Docs
- New exceptions reference page (rocketpy.exceptions) wired into the
  reference index; note UnstableRocketWarning in the rocket stability
  docs (#970).
- tanks.rst: switch examples to radius_function= and add a deprecation
  note (#957).
- forecast.rst: fix the HRRR example (missing code directive + stray
  sentence) (#951).
- airbrakes.rst: document discrete vs continuous controllers
  (sampling_rate=None) (#946).
- rocket_usage.rst: note that Parachute is now an abstract base;
  instantiate HemisphericalParachute (#958).

Tests
- New regression tests: 3D ND-interp NaN outside convex hull (#969),
  abstract Parachute cannot be instantiated (#958), Monte Carlo
  convergence stopping (#922), EnvironmentAnalysis surviving wind API
  (#1041), radial-burn grain geometry over time (#944), RingClusterMotor
  full flight (#924), discrete controller invoked once per node (#949),
  acceleration-based parachute trigger deploys (#911).
- Backfill regression tests for ThrustCurve API timeouts (#940) and
  power_off/on_drag Function objects + _input attributes (#941).


* make format
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ENH: Continuous and Discrete Control

4 participants