Skip to content

Validate actuator arguments so the checks survive -O and None works - #19

Merged
thc1006 merged 8 commits into
developfrom
fix/actuator-validation
Jul 29, 2026
Merged

Validate actuator arguments so the checks survive -O and None works#19
thc1006 merged 8 commits into
developfrom
fix/actuator-validation

Conversation

@thc1006

@thc1006 thc1006 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes #18.

Actuator checked its arguments with bare assert. python -O strips those, so a negative time constant or rate limit was accepted in silence. The checks raise now.

What is refused, and why each one is worth refusing

Every item here is reachable from a config file with a typo in it, and three of them said nothing at all when it happened.

  • A controller that diverges and writes NaN or infinity into an actuator. That value used to be clamped and stored, from where it reaches the forces, the moments and the integrator state. Written as a positive test, because NaN fails every ordered comparison, so value <= 0 lets it through.
  • actuator_range that is not a pair of numbers. It was read by index, so its shape was never checked: three endpoints dropped the third in silence, one raised IndexError, a bare float raised TypeError, and string endpoints, which is what an unquoted YAML value can become, failed inside np.clip with a ufunc loop error. Lists and numpy scalars still work, and an infinite bound still means unbounded.
  • A sampling rate that reaches the actuator and the controller as different types. add_roll_control, add_thrust_vector_control and add_throttle_control built both halves from the same argument and only the actuator normalized it, so sampling_rate="100" left the actuator holding 100.0 and the controller holding "100". The call returned a rocket that looked complete and the failure surfaced later inside Flight at 1 / controller.sampling_rate.
  • demand_rate=None together with a rate limit or a time constant. Neither is implemented for a continuous actuator, and both were accepted with a warning and then ignored. Asking for a 0.1 s lag and getting an instant response is worth stopping for, and the subclass docstrings already say a demand rate is required for those options.

Also in rocket.py: each add_*_control removed the existing controller before building its replacement, so a second call with a rejected argument left the rocket carrying an actuator that simulation would never call. Measured: sampling_rate=-1 on a second call took the controller list from one entry to zero while throttle_control stayed set. Both halves are built first now, which matters more with the checks above in place, since there are now more ways for that second call to be rejected.

The filter coefficient is written as 1 / (1 + tau * rate) rather than forming Ts = 1 / rate first. One expression instead of two, and the two agree to 2.2e-16 across the time constants and rates a real actuator uses.

What this deliberately does not do

An earlier revision also refused a range that cannot clamp anything finite, and a filter coefficient that overflows to zero. Both need numbers no configuration sets: throttle_range=(inf, inf), or a time constant near 1e200. On @zuorenchen's reading they were the wrong kind of thorough, and I agree, so they came out along with the tests built around them, 191 lines in total.

The rule I am using from here, so this is repeatable rather than a judgement call each time: a check earns its place if it stops an honest run being rejected, a dishonest one being accepted, or the program falling over for someone. Every item in the list above meets it. (inf, inf) did not.

Effect on BalloonPoppingChallenge

None on any score. Both shipped scenarios leave gimbal_time_constant, roll_torque_time_constant and throttle_time_constant at null, so the coefficient branch never runs, and their ranges are ordinary finite numbers. Measured by pointing BPC's submodule at this branch: both golden masters pass unchanged, the full suite is 315 passed, and uv lock --check still resolves, so the bump needs no lockfile change.

Checks

ruff check, ruff format --check and pylint clean, pylint exiting 0. Unit 1950 passed, with the four pre-existing test_sensitivity import failures that reproduce with this branch stashed. Integration and acceptance 168 passed. test_actuators.py is 122 tests. Every fix above has a mutation that fails a named test.

Three problems in Actuator.__init__, all reported in #18 and all hit while
building actuator integration tests for BalloonPoppingChallenge.

demand_rate=None is the documented continuous-time mode, but the check read
"demand_rate > 0 or demand_rate is None". Python evaluates the left operand
first, so None > 0 raised TypeError and the mode could not be constructed at all.

The four argument checks were bare asserts, which the interpreter drops under
python -O. I confirmed that a negative time constant, a negative rate limit and
an inverted range were all accepted in silence that way. These read as argument
validation rather than internal invariants, so they now raise ValueError.

actuator_initial_output was assigned without any range check, so an actuator
could start somewhere its own output setter would never have put it, and _reset()
restored that value on every simulation. It now follows the same policy the
setter uses: clamped when clamp is True, warned about when it is False.

The one behaviour change to watch is AssertionError becoming ValueError. The
actuators are fork-only, so the blast radius is this repo's own tests, which are
updated here, and BalloonPoppingChallenge, which does not catch either.

I checked that the clamp cannot move BalloonPoppingChallenge: all three of its
actuators take the default initial output, and 0.0 for gimbal and roll and 1.0
for throttle are inside their ranges, so nothing clamps and no warning fires.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 27, 2026 11:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zuorenchen zuorenchen 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.

Great, thanks. I haven't added the v1.13 tag. Let's merge this as hotfix for v1.13

thc1006 added 4 commits July 28, 2026 19:00
The checks in the previous commit read as the inverted comparison, and
inverting is not the same as negating once NaN is in play. Every ordered
comparison against NaN is false, so `nan <= 0` is false and the value went
through, where the assert being replaced asked `nan > 0` and rejected it.
Four arguments regressed that way: demand_rate, actuator_range,
actuator_rate_limit and actuator_time_constant.

Negate the positive predicate instead, which keeps the assert's meaning
exactly and leaves normal values alone.

Refuse a NaN initial output for the same reason. np.clip returns NaN for
NaN, so clamping would have stored it and _reset() would have restored it,
and there is no direction to clamp it towards in any case.

Drive both the in-process and the -O test from one table of invalid
arguments, so an argument cannot be rejected in the default interpreter
and accepted under -O. Each entry names the message it expects, so a case
cannot pass on some other argument's check. Add the zero cases on both
sides: demand_rate must refuse it, and the non-negative bounds must accept
it, since that is the documented no-dynamics and no-rate-limit setting.

Give the out-of-range warning a stacklevel so it is not reported against
the same line in actuator.py for every caller, and say in the three public
Rocket docstrings what the constructors now enforce.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Pylint was red on this branch. The finding is one line, not the five
negated comparisons it looks like from the outside:

    C0117 unnecessary-negation
    R0124 comparison-with-itself
    not actuator_initial_output == actuator_initial_output

That is the NaN idiom, and it reads as a redundant comparison to a
reader and to pylint alike. Reverting it to `<=` would let NaN back in,
which is what the whole change is about, so it becomes a positive test
on the value instead.

Doing it that way settles two things the comparisons never named.
Infinity is refused where a finite number is required: a demand rate of
infinity makes the sampling period zero, which drives the IIR
coefficient to zero and freezes the output. And a value that is not a
number at all is refused there rather than a few lines later inside
np.clip. The range endpoints are deliberately left alone, since the base
default really is (-inf, inf), meaning an actuator with no range.

The same rule now guards the output setter, which is the more important
half. The constructor rejected a NaN and the setter accepted one on the
next timestep: np.clip returns NaN for NaN and both range comparisons
are false for NaN, so neither the clamped nor the unclamped branch
noticed and it was stored. That is the path an agent writes to.
BalloonPoppingChallenge assigns its actions straight into these setters,
and a policy that goes unstable emits NaN, which from there reaches the
forces, the moments and the integrator state.

Two smaller things found on the way. The -O subprocess helper rendered
NaN as source but not infinity, so the new cases would have died on
NameError while the test read the return code and called it a pass; it
covers both now. And the three checks are extracted, because inlining
them pushed __init__ over pylint's statement limit and suppressing that
would have been the wrong way round.

Also corrects a docstring that promised a feature nobody wrote.
Rocket.add_thrust_vector_control said initial_gimbal_angle could be a
per-axis tuple. ThrustVectorActuator2D hands its value to both
single-axis actuators unchanged and to_dict records only the x-axis
value, so an asymmetric pair could not survive a round trip either. It
used to be accepted and silently applied to both axes and now raises,
since the initial output is converted to a float. Corrected rather than
implemented: the feature belongs upstream rather than in this fork.

Verified by mutation: the setter accepting non-finite values, the check
narrowed back to NaN alone, and the demand rate bound dropped all fail.

pylint rocketpy/ tests/ docs/ rates 10.00/10, ruff check and format are
clean, and tests/unit/rocket passes 260.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The checks added here guard what is passed in. Three things the actuator then
derives from those arguments were left unguarded, and each one puts a
non-finite number into the output the integrator reads.

The range was checked for ordering only, so `(inf, inf)` passed. Clamping a
finite initial output against it stores `inf`, and the next ordinary command
lands on `(1 - alpha) * inf`, which is `0.0 * inf`, and stores NaN. `_reset()`
then restores it at the start of every later flight. `(-inf, -inf)` is the same
in the other direction, and both are reachable through
`Rocket.add_throttle_control`.

Endpoints still are not required to be finite, because an infinite bound is how
this class says "unbounded on that side" and the base default really is
`(-inf, inf)`. The weaker property is what matters and is what is now checked:
clamping a finite value has to give back a finite one. Given `lower <= upper`
that fails exactly when the lower bound is `+inf` or the upper is `-inf`, so
one-sided ranges such as `(0, inf)` are unaffected.

The endpoints are also copied into a new tuple. They were the caller's own
object, so a list mutated after construction moved the range out from under a
validation that had already run.

The IIR coefficient forms `Ts = 1 / demand_rate` before combining it with the
time constant, and that intermediate is not something either argument contains.
A subnormal demand rate of 5e-324 is finite and positive and passes every check
at the boundary, but `1.0 / 5e-324` is `inf` and `inf / (1.0 + inf)` is NaN, so
the first finite command stores NaN and stays there. A time constant of 1e308
overflows the denominator the other way and pins alpha to zero, freezing the
actuator where the answer is about 0.5. Writing the same expression as
`1 / (1 + tau * rate)` avoids forming the intermediate at all. Over 2000 random
pairs across the ranges a real configuration uses, the two agree to 2.2e-16.

The third is in `rocket.py`. Each `add_*_control` built the actuator and the
controller from the same argument, and only the actuator normalized it. With
`sampling_rate="100"` the actuator held `100.0` and the controller held
`"100"`, the call returned a rocket that looked complete, and the failure
surfaced later inside Flight at `1 / controller.sampling_rate`. The controller
now takes the actuator's normalized value. Refusing strings and bools outright
is the other available fix and is a wider behaviour change than this needs.

Thrust vector control reads that value off its x axis: `ThrustVectorActuator2D`
holds no `demand_rate` of its own, only the two axes it builds from the
argument. Its class docstring lists one, along with six other attributes it
also never assigns. That gap is left alone here.

Seven mutations, one per fix and one per call site, each fail a named test; a
control mutation survives. Existing suites unchanged: unit 1945 passed with the
four pre-existing test_sensitivity import failures, integration and acceptance
168 passed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Attacking the previous commit rather than trusting it. Writing the filter
coefficient as `1 / (1 + tau * rate)` removed the overflow in `1 / rate`, and
left one in `tau * rate`: the product goes infinite where neither factor does,
`1 / inf` is 0, and an actuator whose coefficient is 0 never moves. Measured,
tau=1e200 at 1e200 Hz holds its initial 0.5 against a command of 0.25 for the
whole flight. That is the same silent freeze the demand-rate check further up
exists to prevent, so it raises now.

The bound is nowhere near anything real. A 0.01 s time constant at 100 Hz gives
0.5, and 10 s at 1000 Hz gives 1e-4. A small coefficient is what a slow actuator
is; only zero is broken, and only overflow reaches it.

Also restructure the ordering check in the range validator. It was written as
`not lower <= upper` to reject a pair of NaNs, which every ordered comparison
lets through. That reads as a double negative and pylint refuses it as C0117,
which is what turned the Linters job red. Naming NaN first and then comparing
says the same thing in the order a reader expects. Taking pylint's own
suggestion verbatim would have been a bug: `lower > upper` is false for two
NaNs.

The linter gate is the exit code, which is a bitmask, and not the score. The
score stays at 10.00/10 with a convention message present, which is how this got
pushed red. The three I0021 useless-suppression notes in motor.py, sensor.py and
stochastic_rocket.py are information messages, set no bit, and are left alone.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Heads up that two commits landed after your approval, so this is not the diff you signed off on. Both came out of a re-read of the boundary rather than new review feedback.

The checks here guard what is passed in. Three things the actuator derives from those arguments were still unguarded, and each one puts a non-finite number into the output the integrator reads:

  • actuator_range was checked for ordering only, so (inf, inf) passed. Clamping a finite initial output against it stores inf, and the next ordinary command lands on (1 - alpha) * inf, which is 0.0 * inf, and stores NaN. _reset() restores it on every later flight. Reachable through Rocket.add_throttle_control.
  • The IIR coefficient formed 1 / demand_rate first. A subnormal rate of 5e-324 is finite and passes every check, but that intermediate is inf and the coefficient comes out NaN. Written as 1 / (1 + tau * rate) it does not form the intermediate.
  • Each add_*_control built the actuator and the controller from the same argument and only the actuator normalized it, so sampling_rate="100" left the actuator holding 100.0 and the controller holding "100". The failure surfaced later in Flight at 1 / controller.sampling_rate.

The second commit attacks the first: writing the coefficient as 1 / (1 + tau * rate) removes the overflow in 1 / rate and leaves one in tau * rate, where the product goes infinite though neither factor does and the coefficient becomes 0, which is an actuator that never moves.

Nine mutations, one per fix and one per call site, each fail a named test, and a control mutation survives.

One thing I did not touch: ThrustVectorActuator2D documents seven attributes its __init__ never assigns, including demand_rate, which is why the TVC call site reads the rate off the x axis.

If you would rather re-read before this goes in, say so and I will hold it.

Superseded in part. The first two items above were taken back out at 36bda41 after your comment below, since neither needs a number anyone would set. The third stayed, and three further items in the same spirit were added at 2fa75e6. The PR body describes what the branch does now.

@zuorenchen's point on reading this file is right, and it is worth writing down
as a rule rather than a mood: a check earns its place if it stops an honest run
being rejected, a dishonest one being accepted, or the program falling over for
someone. Three of these did none of those.

Gone: the range validator, which existed so `throttle_range=(inf, inf)` could
not clamp a finite value to infinity; the guard on the filter coefficient
overflowing to zero, which needs a time constant near 1e200; and the tests built
around a subnormal demand rate of 5e-324. None of those is a number anyone sets.
Their combined weight was 67 lines of implementation and 124 lines of tests
against a reader who has to get past them to change anything real.

What stays is what a mistake actually looks like. A controller that diverges and
writes NaN or infinity into the actuator is refused at the setter, which was the
original point of #18. A negative time constant, a negative rate limit and a
non-positive demand rate are refused. So is a range whose lower bound is above
its upper. And `add_*_control` hands the controller the sampling rate the
actuator kept, so passing `"100"` from a config file cannot leave the two
holding different types and fail later inside Flight.

The coefficient keeps its one-expression form, `1 / (1 + tau * rate)`. That was
not really about overflow: it is one expression instead of two, agrees with the
old one to 2.2e-16 across the range a real actuator uses, and happens to give
the right answer for a subnormal rate without anyone guarding it.

Suite goes 122 -> 104. ruff and pylint clean, pylint exits 0.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

You are right, and I have taken those parts back out (36bda41).

TLDR: dropped the range validator, the coefficient-overflow guard, and the tests built on a 5e-324 demand rate. 191 lines gone. What stays is a controller writing NaN into an actuator, a negative time constant or rate limit, and the sampling_rate="100" case where the actuator and the controller ended up holding different types. No effect on BPC scores: both shipped scenarios leave the time constants null, and pointing BPC's submodule at this branch leaves both golden masters unchanged, 315 passed.

The part I found genuinely useful was your framing of it. I had been treating "can this input break it" as the question, and the better one is "would anyone ever set this number". So I wrote the rule into the PR body rather than leaving it as taste:

A check earns its place if it stops an honest run being rejected, a dishonest one being accepted, or the program falling over for someone.

That line would have excluded everything you objected to and kept everything I would still argue for, which is a good sign it is the right cut. Having it written down also means the next person does not need either of our backgrounds to apply it, which I think is your point about the barrier.

Since then (2fa75e6): applying that same rule found three things on the other side of it, none of which needs an unusual number, only a typo in a config file.

actuator_range was read by index and its shape was never checked, so three endpoints dropped the third in silence and string endpoints failed inside np.clip. Each add_*_control removed the existing controller before building the replacement, so a second call with a bad argument left the rocket holding an actuator that simulation would never call, silently; that one got easier to hit because of the checks I had just added. And demand_rate=None with a time constant was accepted with a warning and then ignored, so asking for a 0.1 s lag gave an instant response.

Suite is 122 tests now, up from 104 after the trim, and each of the three has a mutation that fails a named test.

One distinction I would still draw, and I would like your read on it. A competitor's AI finding a strange but legal way to fly is the competition working, and I agree it would be fun to watch. Someone editing the recorded result to claim balloons they never reached is a different thing, and the submission checks in BPC are aimed at that rather than at surviving odd inputs. If you would rather I hold those to a lighter standard too, say so and I will.

These are on the other side of the line from the ones taken out last commit.
Nothing here needs an unusual number, only a config file with a typo in it, and
two of the three say nothing at all when it happens.

`actuator_range` was read by index, so its shape was never checked. Three
endpoints dropped the third in silence, one raised IndexError, a bare float
raised TypeError, and string endpoints, which is what a YAML value without
quotes handling looks like, failed inside np.clip with a ufunc loop error. It
is now a pair of numbers or a ValueError naming the endpoint. Lists and numpy
scalars still work, and an infinite bound still means unbounded.

Each `add_*_control` removed the existing controller before building its
replacement. A second call with a rejected argument then left the rocket
carrying an actuator that simulation would never call, with no error after the
one that caused it: measured, `sampling_rate=-1` on a second call took the
controller list from one entry to zero while `throttle_control` stayed set. Both
halves are built first now.

`demand_rate=None` with a rate limit or a time constant was accepted with a
warning and then ignored. Asking for a 0.1 s lag and getting an instant response
is worth stopping for, and the subclass docstrings already say a demand rate is
required for those options. It raises at construction now.

Four mutations, one per fix, each fail a named test. 122 tests, up from 104.
pylint exits 0: rocket.py was one line over its 3050 limit after this, so the
comments came down to two lines each.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Ready from my side. CI green on 2fa75e6: lint plus all six platform legs.

Since your approval on f55358e0 the branch changed twice, so here is what it is now rather than what you signed off on.

Taken out after your comment about this being too fine-grained: the range validator that refused (inf, inf), the guard on a filter coefficient overflowing to zero, and the tests built on a 5e-324 demand rate. 191 lines. None of those is a number anyone sets.

Kept and added, all reachable from a config file with a typo in it: a controller writing NaN into an actuator, a negative time constant or rate limit, an actuator_range that is not a pair of numbers (three endpoints used to drop the third in silence, string endpoints failed inside np.clip), sampling_rate="100" leaving the actuator and the controller holding different types, and demand_rate=None with a time constant, which was accepted with a warning and then ignored.

One of those is worth calling out because my own earlier commits made it easier to hit: each add_*_control removed the existing controller before building the replacement, so a second call with a rejected argument left the rocket holding an actuator that simulation would never call. More validation means more ways for that second call to be rejected. Both halves are built first now.

No effect on BPC scores. Both shipped scenarios leave the three time constants null, so the coefficient branch never runs. Measured by pointing BPC's submodule at this branch: both golden masters unchanged, 315 passed, uv lock --check still resolves.

122 tests here, up from 88 on develop. Each fix has a mutation that fails a named test.

Happy for you to merge whenever suits. After this lands, BPC needs develop to reach master before it can pick the change up, and that release is yours.

YAML reads `yes` and `on` as True and `float(True)` is 1.0, so
`sampling_rate: yes` became 1 Hz instead of 100 with nothing said. The previous
revision of the test file pinned that as the contract, which is worse than
leaving it unspecified. Refused now, the same way a boolean is already refused
as a range endpoint.

`float(10**400)` raises OverflowError, so one argument in the same validator
came back as a different exception type from everything beside it.

126 tests, up from 122.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 merged commit 489f1a0 into develop Jul 29, 2026
8 checks passed
thc1006 added a commit that referenced this pull request Jul 29, 2026
REL: v1.13 hotfix, actuator argument validation (#19)
thc1006 added a commit that referenced this pull request Jul 29, 2026
`docs.yml` triggered on a base of master only, so the only pull request
that ever started it was develop into master. A docstring that does not
build therefore lands on develop unremarked and first fails the merge
that was meant to ship it, which is what #19 and #23 hit and why the four
warnings in the commit before this one went unnoticed for three days.

develop as well. The same paths filter, so it still only runs when
something it reads has changed.

This also makes the commit before it self checking: without this, a pull
request into develop cannot start the job that would prove the warnings
are gone, and the only evidence would be a measurement in the
description.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
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.

3 participants