Skip to content

Commit 4ecd10f

Browse files
committed
fix: validation stopped at the arguments, and three paths went around it
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>
1 parent f55358e commit 4ecd10f

4 files changed

Lines changed: 261 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ Attention: The newest changes should be on top -->
3636

3737
### Fixed
3838

39+
- BUG: actuator argument validation reached the output through a range, a
40+
derived filter coefficient and a controller [#19](https://github.com/ARRC-Rocket/ActiveRocketPy/pull/19)
41+
3942
## [v1.13.0] - 2026-07-21
4043

4144
### Added

rocketpy/rocket/actuator/actuator.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,49 @@ def _non_negative_or_none(value, description):
4747
return number
4848

4949

50+
def _range_or_raise(value, description):
51+
"""Return the two endpoints as a tuple, refusing a range that cannot clamp.
52+
53+
Endpoints are not held to ``_finite_or_raise``. An infinite bound is how
54+
this class says "unbounded on that side", and the base default really is
55+
``(-inf, inf)``. What has to hold instead is weaker and is the property the
56+
range is used for: clamping a finite value against it has to give back a
57+
finite value.
58+
59+
That fails in exactly two cases, and ordering makes them one. Given
60+
``lower <= upper``, a lower bound of ``+inf`` forces the upper bound to
61+
match, and an upper bound of ``-inf`` forces the lower bound to match; in
62+
both, ``np.clip`` returns the infinity. So ``(inf, inf)`` passed the
63+
ordering check, took a finite initial output of 0.5, and stored ``inf``,
64+
after which the first ordinary command landed on
65+
``(1 - alpha) * inf == 0.0 * inf`` and stored NaN, which ``_reset()`` then
66+
restored on every subsequent flight. A one-sided bound such as ``(0, inf)``
67+
is unaffected and stays allowed.
68+
69+
The endpoints are also copied into a new tuple. They were stored as the
70+
caller's own object, so a list mutated after construction moved the range
71+
out from under a validation that had already run.
72+
"""
73+
try:
74+
lower, upper = value
75+
except (TypeError, ValueError) as error:
76+
raise ValueError(f"{description} must be a pair of numbers.") from error
77+
try:
78+
lower = float(lower)
79+
upper = float(upper)
80+
except (TypeError, ValueError) as error:
81+
raise ValueError(f"{description} endpoints must be numbers.") from error
82+
# Written as a positive test because NaN fails every ordered comparison, so
83+
# a range of two NaNs would slip through a check phrased as a negation.
84+
if not lower <= upper:
85+
raise ValueError(f"{description}[0] must be <= {description}[1].")
86+
if lower == np.inf or upper == -np.inf:
87+
raise ValueError(
88+
f"{description} {(lower, upper)} cannot clamp anything to a finite value."
89+
)
90+
return (lower, upper)
91+
92+
5093
class Actuator(ABC):
5194
"""Abstract class used to define actuators.
5295
@@ -102,12 +145,12 @@ def __init__(
102145
# zero and freezes the output.
103146
#
104147
# The range endpoints are deliberately not put through this. The base
105-
# default really is (-inf, inf), meaning an actuator with no range.
148+
# default really is (-inf, inf), meaning an actuator with no range, so
149+
# they get the weaker check in _range_or_raise instead.
106150
self.demand_rate = _positive_or_none(demand_rate, "demand_rate")
107151

108-
if not actuator_range[0] <= actuator_range[1]:
109-
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
110-
self.actuator_range = actuator_range
152+
self.actuator_range = _range_or_raise(actuator_range, "actuator_range")
153+
actuator_range = self.actuator_range
111154

112155
self.actuator_rate_limit = _non_negative_or_none(
113156
actuator_rate_limit, "actuator_rate_limit"
@@ -153,9 +196,19 @@ def _update_iir_coefficients(self):
153196

154197
if self.actuator_time_constant is not None and self.actuator_time_constant > 0:
155198
if self.demand_rate is not None:
156-
demand_period = 1.0 / self.demand_rate
157-
self._alpha = demand_period / (
158-
self.actuator_time_constant + demand_period
199+
# Algebraically Ts / (tau + Ts) with Ts = 1 / demand_rate, but
200+
# written without forming Ts. That intermediate overflows for a
201+
# subnormal demand rate: 1.0 / 5e-324 is inf, inf / (1.0 + inf)
202+
# is NaN, and the filter then stores NaN out of a command that
203+
# passed every check on the way in. At the other end a huge time
204+
# constant makes the denominator overflow and pins alpha to
205+
# zero, freezing the actuator, where 1e-308 with 1e308 should
206+
# give about 0.5. Neither shape is reachable from a physical
207+
# configuration, and neither costs anything to rule out: over
208+
# 2000 random pairs across the ranges that are, the two forms
209+
# agree to 2.2e-16.
210+
self._alpha = 1.0 / (
211+
1.0 + self.actuator_time_constant * self.demand_rate
159212
)
160213
else:
161214
warnings.warn(

rocketpy/rocket/rocket.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2095,7 +2095,21 @@ def add_thrust_vector_control(
20952095
_controller = _Controller(
20962096
interactive_objects=thrust_vector_control,
20972097
controller_function=controller_function,
2098-
sampling_rate=sampling_rate,
2098+
# The actuator's normalized rate, not the argument. The actuator
2099+
# runs its own validation and stores a float, so handing the
2100+
# controller the original leaves the two holding different types
2101+
# for one quantity: sampling_rate="100" gives the actuator 100.0
2102+
# and the controller "100", this call returns successfully, and the
2103+
# failure surfaces later in Flight at 1 / controller.sampling_rate,
2104+
# by which point the rocket is already half built.
2105+
#
2106+
# Read off the x axis because ThrustVectorActuator2D holds no
2107+
# demand_rate of its own. Its class docstring lists one, along with
2108+
# six other attributes it also never assigns, but __init__ only
2109+
# forwards the argument to the two axes. Both are built from that
2110+
# one argument, so either axis gives the same number, and reaching
2111+
# into .x is what Flight already does to reset this class.
2112+
sampling_rate=thrust_vector_control.x.demand_rate,
20992113
initial_observed_variables=initial_observed_variables,
21002114
name=controller_name,
21012115
)
@@ -2228,7 +2242,8 @@ def add_roll_control(
22282242
_controller = _Controller(
22292243
interactive_objects=roll_control,
22302244
controller_function=controller_function,
2231-
sampling_rate=sampling_rate,
2245+
# The actuator's normalized rate. See add_thrust_vector_control.
2246+
sampling_rate=roll_control.demand_rate,
22322247
initial_observed_variables=initial_observed_variables,
22332248
name=controller_name,
22342249
)
@@ -2364,7 +2379,8 @@ def add_throttle_control(
23642379
_controller = _Controller(
23652380
interactive_objects=throttle_control,
23662381
controller_function=controller_function,
2367-
sampling_rate=sampling_rate,
2382+
# The actuator's normalized rate. See add_thrust_vector_control.
2383+
sampling_rate=throttle_control.demand_rate,
23682384
initial_observed_variables=initial_observed_variables,
23692385
name=controller_name,
23702386
)

tests/unit/rocket/test_actuators.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,182 @@ def test_clamping_applied(self):
693693
actuator.actuator_output = 15.0
694694
# Output should be clamped to range
695695
assert actuator.roll_torque == 10.0
696+
697+
698+
class TestARangeThatCannotClampIsRefused:
699+
"""The range was checked for ordering only, and stored by reference.
700+
701+
Both halves let a finite value become a non-finite stored output, which is
702+
the one thing the validation around it exists to prevent. Measured before
703+
the fix: ``(inf, inf)`` passed ``lower <= upper``, ``np.clip(0.5, inf, inf)``
704+
stored ``inf``, and the next ordinary command reached
705+
``(1 - alpha) * inf``, which is ``0.0 * inf``, and stored NaN. ``_reset()``
706+
put it back at the start of every later flight.
707+
"""
708+
709+
@pytest.mark.parametrize("limits", [(math.inf, math.inf), (-math.inf, -math.inf)])
710+
def test_a_range_with_nothing_finite_in_it_is_refused(self, limits):
711+
with pytest.raises(ValueError, match="clamp"):
712+
ThrottleActuator(throttle_range=limits, initial_throttle=0.5)
713+
714+
def test_a_range_of_nans_is_refused(self):
715+
"""NaN fails every ordered comparison, so the ordering check catches it
716+
only because that check is written as a positive test."""
717+
with pytest.raises(ValueError):
718+
ThrottleActuator(throttle_range=(math.nan, math.nan))
719+
720+
@pytest.mark.parametrize("limits", [(0.0,), (0.0, 1.0, 2.0), 1.0])
721+
def test_a_range_that_is_not_a_pair_is_refused(self, limits):
722+
with pytest.raises(ValueError):
723+
ThrottleActuator(throttle_range=limits)
724+
725+
@pytest.mark.parametrize(
726+
"limits",
727+
[(-math.inf, math.inf), (0.0, math.inf), (-math.inf, 1.0), (0.0, 1.0)],
728+
)
729+
def test_a_range_that_can_clamp_still_builds(self, limits):
730+
"""The half that stops this being satisfied by refusing everything.
731+
732+
An infinite bound is how this class says "unbounded on that side", and
733+
the base default really is ``(-inf, inf)``, so only a range with no
734+
finite value on the clamping side may be refused.
735+
"""
736+
actuator = ThrottleActuator(throttle_range=limits, initial_throttle=0.5)
737+
actuator.throttle = 0.25
738+
739+
assert math.isfinite(actuator.throttle)
740+
741+
def test_mutating_the_range_passed_in_does_not_move_the_actuator(self):
742+
"""It was the caller's own list, so a validated invariant could be
743+
edited away after the fact."""
744+
limits = [0.0, 1.0]
745+
actuator = ThrottleActuator(throttle_range=limits, initial_throttle=0.5)
746+
747+
limits[:] = [math.inf, math.inf]
748+
actuator.throttle = 0.25
749+
750+
assert actuator.actuator_range == (0.0, 1.0)
751+
assert actuator.throttle == 0.25
752+
753+
754+
class TestTheFilterCoefficientStaysFinite:
755+
"""``alpha`` is derived, and validating only the inputs left it unchecked.
756+
757+
``Ts / (tau + Ts)`` with ``Ts = 1 / demand_rate`` forms an intermediate that
758+
the arguments themselves never contain. Both arguments below are finite and
759+
pass every check at the boundary.
760+
"""
761+
762+
def test_a_subnormal_demand_rate_does_not_give_a_nan_coefficient(self):
763+
"""Measured before the fix: ``1.0 / 5e-324`` is inf, ``inf / (1.0 + inf)``
764+
is NaN, and the first finite command then stored NaN and stayed there."""
765+
actuator = ThrottleActuator(
766+
demand_rate=5e-324,
767+
throttle_time_constant=1.0,
768+
throttle_range=(0.0, 1.0),
769+
initial_throttle=0.5,
770+
)
771+
actuator.throttle = 0.25
772+
773+
assert math.isfinite(actuator._alpha)
774+
assert math.isfinite(actuator.throttle)
775+
776+
def test_a_huge_time_constant_does_not_pin_the_coefficient_to_zero(self):
777+
"""The other end of the same overflow. The denominator went infinite and
778+
alpha came out 0, which freezes the actuator at its initial value; the
779+
answer here is about 0.5."""
780+
actuator = ThrottleActuator(
781+
demand_rate=1e-308,
782+
throttle_time_constant=1e308,
783+
throttle_range=(0.0, 1.0),
784+
initial_throttle=0.5,
785+
)
786+
787+
assert actuator._alpha == pytest.approx(0.5)
788+
789+
@pytest.mark.parametrize(
790+
"demand_rate, time_constant",
791+
[(1e-3, 1e-6), (1.0, 1.0), (100.0, 0.05), (1e4, 1e3)],
792+
)
793+
def test_the_two_forms_agree_where_both_work(self, demand_rate, time_constant):
794+
"""So the rewrite is a rewrite and not a change of behaviour. Measured
795+
over 2000 random pairs across these ranges: the largest difference is
796+
2.2e-16."""
797+
actuator = ThrottleActuator(
798+
demand_rate=demand_rate,
799+
throttle_time_constant=time_constant,
800+
throttle_range=(0.0, 1.0),
801+
)
802+
demand_period = 1.0 / demand_rate
803+
804+
assert actuator._alpha == pytest.approx(
805+
demand_period / (time_constant + demand_period), rel=1e-12
806+
)
807+
808+
809+
def _no_op_controller(time, sampling_rate, state, state_history, observed, interactive): # pylint: disable=unused-argument
810+
"""A controller that commands nothing, so these tests are about the wiring."""
811+
return None
812+
813+
814+
class TestTheControllerAndTheActuatorShareOneSamplingRate:
815+
"""``add_*_control`` built the two halves from the same argument.
816+
817+
The actuator normalizes what it is given and stores a float. The controller
818+
was handed the original object, so one quantity ended up held twice, in two
819+
types. Nothing complained: the call returned a rocket that looked complete,
820+
and the failure surfaced later inside Flight at
821+
``controller_time_step = 1 / controller.sampling_rate``, by which point the
822+
actuator and the controller were already attached.
823+
824+
Passing the actuator's normalized value is the smaller of the two available
825+
fixes. Refusing strings and bools outright is the other, and it is a wider
826+
behaviour change than this needs.
827+
"""
828+
829+
# The third entry reaches through .x because ThrustVectorActuator2D keeps
830+
# no demand_rate of its own, only the two axes it builds from the argument.
831+
ADDERS = [
832+
("add_roll_control", "roll_control", {"max_roll_torque": 10.0}, False),
833+
("add_throttle_control", "throttle_control", {}, False),
834+
(
835+
"add_thrust_vector_control",
836+
"thrust_vector_control",
837+
{"max_gimbal_angle": 5.0},
838+
True,
839+
),
840+
]
841+
842+
@staticmethod
843+
def _rate_of(rocket, attribute, per_axis):
844+
actuator = getattr(rocket, attribute)
845+
return (actuator.x if per_axis else actuator).demand_rate
846+
847+
@pytest.mark.parametrize("adder, attribute, extra, per_axis", ADDERS)
848+
@pytest.mark.parametrize("given", ["100", True, 100])
849+
def test_both_halves_hold_the_same_normalized_value(
850+
self, calisto, adder, attribute, extra, per_axis, given
851+
):
852+
"""``True`` is in here because ``float(True)`` is 1.0, so a bool reaches
853+
the actuator as a rate and used to reach the controller as a bool."""
854+
getattr(calisto, adder)(
855+
controller_function=_no_op_controller, sampling_rate=given, **extra
856+
)
857+
rate = self._rate_of(calisto, attribute, per_axis)
858+
controller = calisto._controllers[-1]
859+
860+
assert controller.sampling_rate == rate
861+
assert type(controller.sampling_rate) is type(rate)
862+
863+
@pytest.mark.parametrize(
864+
"adder, extra", [(adder, extra) for adder, _, extra, _ in ADDERS]
865+
)
866+
def test_the_controller_rate_is_usable_as_a_time_step(self, calisto, adder, extra):
867+
"""The exact expression Flight evaluates, which is where a string blew
868+
up with a TypeError after the rocket had already been assembled."""
869+
getattr(calisto, adder)(
870+
controller_function=_no_op_controller, sampling_rate="100", **extra
871+
)
872+
controller = calisto._controllers[-1]
873+
874+
assert 1 / controller.sampling_rate == pytest.approx(0.01)

0 commit comments

Comments
 (0)