Skip to content

Commit f55358e

Browse files
committed
Validate the value, not the reflexivity of a comparison
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>
1 parent 35d1702 commit f55358e

3 files changed

Lines changed: 139 additions & 29 deletions

File tree

rocketpy/rocket/actuator/actuator.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@
44
import numpy as np
55

66

7+
def _finite_or_raise(value, description):
8+
"""Return ``value`` as a float, refusing anything that is not finite.
9+
10+
A positive test on the value rather than a negated comparison. NaN fails
11+
every ordered comparison, so a check written as ``value <= 0`` accepts it
12+
where the assert it replaced rejected it; and the self-comparison that
13+
caught it, ``not value == value``, reads as a redundant comparison to a
14+
reader and to pylint alike.
15+
16+
This also settles two cases the comparisons never named. Infinity is
17+
refused, since an actuator cannot start at or be driven to one, and a value
18+
that is not a number at all is refused here rather than a few lines later
19+
inside ``np.clip``.
20+
"""
21+
try:
22+
number = float(value)
23+
except (TypeError, ValueError) as error:
24+
raise ValueError(f"{description} must be a number.") from error
25+
if not np.isfinite(number):
26+
raise ValueError(f"{description} must be a finite number.")
27+
return number
28+
29+
30+
def _positive_or_none(value, description):
31+
"""An optional number that has to be finite and greater than zero."""
32+
if value is None:
33+
return None
34+
number = _finite_or_raise(value, description)
35+
if number <= 0:
36+
raise ValueError(f"{description} must be positive or None.")
37+
return number
38+
39+
40+
def _non_negative_or_none(value, description):
41+
"""An optional number that has to be finite and not below zero."""
42+
if value is None:
43+
return None
44+
number = _finite_or_raise(value, description)
45+
if number < 0:
46+
raise ValueError(f"{description} must be non-negative or None.")
47+
return number
48+
49+
750
class Actuator(ABC):
851
"""Abstract class used to define actuators.
952
@@ -52,38 +95,41 @@ def __init__(
5295
# These are argument checks rather than internal invariants, so they raise
5396
# instead of asserting: python -O drops assert statements, and a negative
5497
# time constant or rate limit would then be accepted in silence.
55-
# Negating the positive predicate rather than inverting the comparison.
56-
# Every ordered comparison against NaN is false, so `nan <= 0` is false
57-
# and would have let it through, where the assert this replaces asked
58-
# `nan > 0` and rejected it. Same for the three below.
59-
if demand_rate is not None and not demand_rate > 0:
60-
raise ValueError("demand_rate must be positive or None.")
61-
self.demand_rate = demand_rate
98+
# Finite first, then the bound, so each check says one thing. Written
99+
# as a negated comparison these accepted NaN, which fails every ordered
100+
# comparison, and infinity, which passes them: a demand rate of infinity
101+
# makes the sampling period zero, which drives the IIR coefficient to
102+
# zero and freezes the output.
103+
#
104+
# The range endpoints are deliberately not put through this. The base
105+
# default really is (-inf, inf), meaning an actuator with no range.
106+
self.demand_rate = _positive_or_none(demand_rate, "demand_rate")
62107

63108
if not actuator_range[0] <= actuator_range[1]:
64109
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
65110
self.actuator_range = actuator_range
66111

67-
if actuator_rate_limit is not None and not actuator_rate_limit >= 0:
68-
raise ValueError("actuator_rate_limit must be non-negative or None.")
69-
self.actuator_rate_limit = actuator_rate_limit
112+
self.actuator_rate_limit = _non_negative_or_none(
113+
actuator_rate_limit, "actuator_rate_limit"
114+
)
70115

71116
self.clamp = clamp
72117

73-
if actuator_time_constant is not None and not actuator_time_constant >= 0:
74-
raise ValueError("actuator_time_constant must be non-negative or None.")
75-
self.actuator_time_constant = actuator_time_constant
118+
self.actuator_time_constant = _non_negative_or_none(
119+
actuator_time_constant, "actuator_time_constant"
120+
)
76121
self._update_iir_coefficients()
77122

78123
# An initial output outside the range used to survive here and come back
79124
# on every _reset(), even though the output setter would never let the
80125
# actuator reach such a value afterwards. Treat it the way the setter
81126
# treats any other out-of-range value, so the two agree.
82-
# NaN first: np.clip propagates it, so clamping would store NaN and
83-
# _reset() would restore it, and there is no direction to clamp it
84-
# towards in any case.
85-
if not actuator_initial_output == actuator_initial_output:
86-
raise ValueError(f"Actuator '{name}' initial output must be a number.")
127+
# Refused before clamping: np.clip propagates NaN, so clamping would
128+
# store it and _reset() would restore it, and there is no direction to
129+
# clamp it towards in any case.
130+
actuator_initial_output = _finite_or_raise(
131+
actuator_initial_output, f"Actuator '{name}' initial output"
132+
)
87133
if self.clamp:
88134
actuator_initial_output = float(
89135
np.clip(actuator_initial_output, actuator_range[0], actuator_range[1])
@@ -136,6 +182,18 @@ def actuator_output(self, value):
136182
-------
137183
None
138184
"""
185+
# The same rule as the initial output, so the two agree. It used to
186+
# reject a NaN handed to the constructor and accept one handed to the
187+
# setter on the next timestep: np.clip returns NaN for NaN, and both
188+
# range comparisons are false for NaN, so neither branch noticed and it
189+
# was stored.
190+
#
191+
# This is the path an agent writes to. BalloonPoppingChallenge assigns
192+
# its actions straight into these setters, and a policy that goes
193+
# unstable emits NaN, which from here reaches the forces, the moments
194+
# and the integrator state.
195+
value = _finite_or_raise(value, f"Actuator '{self.name}' output")
196+
139197
# Apply first-order IIR actuator dynamics
140198
value = self._alpha * value + (1 - self._alpha) * self._actuator_output
141199

rocketpy/rocket/rocket.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,12 +2031,19 @@ def add_thrust_vector_control(
20312031
If True, the simulation will clamp gimbal angles to the range
20322032
[-max_gimbal_angle, max_gimbal_angle]. If False, a warning is
20332033
issued when gimbal angles exceed the range. Default is True.
2034-
initial_gimbal_angle : int, float, tuple, list
2035-
The initial gimbal angle in degrees. If a single value is provided,
2036-
it is used for both x and y gimbal angles. If a tuple or list is
2037-
provided, the first element is used for the x-axis and the second
2038-
for the y-axis. A value outside the range is clamped into it when
2039-
clamp is True, and warned about otherwise. Default is 0.0.
2034+
initial_gimbal_angle : int, float
2035+
The initial gimbal angle in degrees, used for both the x and y
2036+
axes. A value outside the range is clamped into it when clamp is
2037+
True, and warned about otherwise. Default is 0.0.
2038+
2039+
A per-axis tuple or list was described here and has never been
2040+
implemented: ``ThrustVectorActuator2D`` hands the value it is given
2041+
to both single-axis actuators unchanged, and ``to_dict`` records
2042+
only the x-axis value, so an asymmetric pair could not survive a
2043+
round trip either. It used to be accepted and silently applied to
2044+
both axes; it now raises, because the initial output is converted
2045+
to a float. Corrected here rather than implemented, since the
2046+
feature belongs upstream rather than in this fork.
20402047
gimbal_time_constant : float, optional
20412048
Time constant for the gimbal dynamics in seconds. Must be
20422049
non-negative. If None, no gimbal dynamics are applied. Default is

tests/unit/rocket/test_actuators.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -457,18 +457,19 @@ def test_time_constant_iir_filter(self):
457457

458458

459459
NAN = float("nan")
460+
INF = float("inf")
460461

461462

462463
def _as_source(value):
463464
"""Render a value as source the ``-O`` subprocess can evaluate.
464465
465466
``repr`` is almost enough, except that it renders NaN as the bare name
466-
``nan``, which the subprocess does not have bound. Left as ``repr`` the NaN
467-
cases died on NameError, and a test that only checked the return code would
468-
have called that a pass.
467+
``nan`` and infinity as ``inf``, neither of which the subprocess has bound.
468+
Left as ``repr`` those cases died on NameError, and a test that only checked
469+
the return code would have called that a pass.
469470
"""
470-
if isinstance(value, float) and math.isnan(value):
471-
return 'float("nan")'
471+
if isinstance(value, float) and not math.isfinite(value):
472+
return f'float("{value}")'
472473
if isinstance(value, tuple):
473474
return "(" + ", ".join(_as_source(item) for item in value) + ",)"
474475
return repr(value)
@@ -498,13 +499,57 @@ def _as_source(value):
498499
# clamp is what would otherwise absorb an out-of-range initial value, and
499500
# np.clip returns NaN for NaN, so both settings have to refuse it.
500501
(ThrottleActuator, {"initial_throttle": NAN, "clamp": False}, "initial output"),
502+
# Infinity was never named by the comparisons. An actuator cannot start at
503+
# one, and clamping would quietly turn it into a range endpoint.
504+
(ThrottleActuator, {"initial_throttle": INF}, "initial output"),
505+
(RollActuator, {"demand_rate": INF}, "demand_rate"),
501506
]
502507
INVALID_IDS = [
503508
f"{cls.__name__}-{'-'.join(kwargs)}-{'clamped' if kwargs.get('clamp', True) else 'unclamped'}"
504509
for cls, kwargs, _ in INVALID_ARGUMENTS
505510
]
506511

507512

513+
class TestTheOutputSetterRefusesWhatTheConstructorDoes:
514+
"""The two used to disagree, and the setter is the one an agent writes to.
515+
516+
A NaN handed to the constructor was rejected; the same NaN handed to the
517+
setter on the next timestep was stored. ``np.clip`` returns NaN for NaN, and
518+
both range comparisons are false for NaN, so neither the clamped nor the
519+
unclamped branch noticed.
520+
521+
BalloonPoppingChallenge assigns agent actions straight into these setters,
522+
and a policy that goes unstable emits NaN, which from there reaches the
523+
forces, the moments and the integrator state.
524+
"""
525+
526+
@pytest.mark.parametrize("clamp", [True, False], ids=["clamped", "unclamped"])
527+
@pytest.mark.parametrize("value", [NAN, INF, -INF], ids=["nan", "inf", "-inf"])
528+
def test_a_non_finite_command_is_refused(self, clamp, value):
529+
actuator = ThrottleActuator(clamp=clamp)
530+
531+
with pytest.raises(ValueError, match="output"):
532+
actuator.actuator_output = value
533+
534+
@pytest.mark.parametrize("clamp", [True, False], ids=["clamped", "unclamped"])
535+
def test_an_ordinary_command_still_goes_through(self, clamp):
536+
"""Or refusing everything would satisfy the test above."""
537+
actuator = ThrottleActuator(clamp=clamp)
538+
539+
actuator.actuator_output = 0.5
540+
541+
assert actuator.actuator_output == pytest.approx(0.5)
542+
543+
def test_the_stored_output_is_untouched_by_a_refused_command(self):
544+
actuator = ThrottleActuator()
545+
actuator.actuator_output = 0.5
546+
547+
with pytest.raises(ValueError):
548+
actuator.actuator_output = NAN
549+
550+
assert actuator.actuator_output == pytest.approx(0.5)
551+
552+
508553
class TestActuatorValidation:
509554
"""Test suite for actuator parameter validation."""
510555

0 commit comments

Comments
 (0)