Skip to content

Commit 35d1702

Browse files
committed
Reject NaN in the actuator argument checks
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>
1 parent 99eef7e commit 35d1702

3 files changed

Lines changed: 151 additions & 56 deletions

File tree

rocketpy/rocket/actuator/actuator.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,21 +52,25 @@ def __init__(
5252
# These are argument checks rather than internal invariants, so they raise
5353
# instead of asserting: python -O drops assert statements, and a negative
5454
# time constant or rate limit would then be accepted in silence.
55-
if demand_rate is not None and demand_rate <= 0:
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:
5660
raise ValueError("demand_rate must be positive or None.")
5761
self.demand_rate = demand_rate
5862

59-
if actuator_range[0] > actuator_range[1]:
63+
if not actuator_range[0] <= actuator_range[1]:
6064
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
6165
self.actuator_range = actuator_range
6266

63-
if actuator_rate_limit is not None and actuator_rate_limit < 0:
67+
if actuator_rate_limit is not None and not actuator_rate_limit >= 0:
6468
raise ValueError("actuator_rate_limit must be non-negative or None.")
6569
self.actuator_rate_limit = actuator_rate_limit
6670

6771
self.clamp = clamp
6872

69-
if actuator_time_constant is not None and actuator_time_constant < 0:
73+
if actuator_time_constant is not None and not actuator_time_constant >= 0:
7074
raise ValueError("actuator_time_constant must be non-negative or None.")
7175
self.actuator_time_constant = actuator_time_constant
7276
self._update_iir_coefficients()
@@ -75,14 +79,20 @@ def __init__(
7579
# on every _reset(), even though the output setter would never let the
7680
# actuator reach such a value afterwards. Treat it the way the setter
7781
# 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.")
7887
if self.clamp:
7988
actuator_initial_output = float(
8089
np.clip(actuator_initial_output, actuator_range[0], actuator_range[1])
8190
)
8291
elif not actuator_range[0] <= actuator_initial_output <= actuator_range[1]:
8392
warnings.warn(
8493
f"Actuator '{name}' initial output {actuator_initial_output} "
85-
f"is outside its range {actuator_range}."
94+
f"is outside its range {actuator_range}.",
95+
stacklevel=2,
8696
)
8797

8898
self.actuator_initial_output = actuator_initial_output

rocketpy/rocket/rocket.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,14 +2017,16 @@ def add_thrust_vector_control(
20172017
sampling_rate : float
20182018
The sampling rate of the controller function in Hertz (Hz). This
20192019
means that the controller function will be called every
2020-
`1/sampling_rate` seconds.
2020+
`1/sampling_rate` seconds. Must be positive, or None for a
2021+
continuous-time controller called at every integration step.
20212022
max_gimbal_angle : int, float
20222023
Maximum gimbal angle in degrees. Both x and y gimbal
20232024
angles are clamped to this range if clamp is True. Must be
20242025
non-negative.
20252026
gimbal_rate_limit : int, float
20262027
Maximum gimbal rate in degrees per second. Both x and y gimbal
2027-
angles are limited to this rate of change. Default is None, no rate limit.
2028+
angles are limited to this rate of change. Must be non-negative.
2029+
Default is None, no rate limit.
20282030
clamp : bool, optional
20292031
If True, the simulation will clamp gimbal angles to the range
20302032
[-max_gimbal_angle, max_gimbal_angle]. If False, a warning is
@@ -2033,10 +2035,12 @@ def add_thrust_vector_control(
20332035
The initial gimbal angle in degrees. If a single value is provided,
20342036
it is used for both x and y gimbal angles. If a tuple or list is
20352037
provided, the first element is used for the x-axis and the second
2036-
for the y-axis. Default is 0.0.
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.
20372040
gimbal_time_constant : float, optional
2038-
Time constant for the gimbal dynamics in seconds. If None, no
2039-
gimbal dynamics are applied. Default is None.
2041+
Time constant for the gimbal dynamics in seconds. Must be
2042+
non-negative. If None, no gimbal dynamics are applied. Default is
2043+
None.
20402044
initial_observed_variables : list, optional
20412045
A list of the initial values of the variables that the controller
20422046
function manages. This list is used to initialize the
@@ -2152,7 +2156,8 @@ def add_roll_control(
21522156
sampling_rate : float
21532157
The sampling rate of the controller function in Hertz (Hz). This
21542158
means that the controller function will be called every
2155-
`1/sampling_rate` seconds.
2159+
`1/sampling_rate` seconds. Must be positive, or None for a
2160+
continuous-time controller called at every integration step.
21562161
max_roll_torque : int, float
21572162
Maximum roll torque magnitude in N·m. Must be non-negative.
21582163
torque_rate_limit : int, float
@@ -2163,9 +2168,12 @@ def add_roll_control(
21632168
[-max_roll_torque, max_roll_torque]. If False, a warning is
21642169
issued when roll torque exceeds the range. Default is True.
21652170
initial_roll_torque : int, float
2166-
Initial roll torque in N·m. Default is 0.0.
2171+
Initial roll torque in N·m. A value outside the range is clamped
2172+
into it when clamp is True, and warned about otherwise. Default is
2173+
0.0.
21672174
roll_torque_time_constant : float, optional
2168-
Time constant for the roll torque dynamics in seconds. Default is None, no dynamics are applied.
2175+
Time constant for the roll torque dynamics in seconds. Must be
2176+
non-negative. Default is None, no dynamics are applied.
21692177
initial_observed_variables : list, optional
21702178
A list of the initial values of the variables that the controller
21712179
function manages. This list is used to initialize the
@@ -2281,7 +2289,8 @@ def add_throttle_control(
22812289
sampling_rate : float
22822290
The sampling rate of the controller function in Hertz (Hz). This
22832291
means that the controller function will be called every
2284-
`1/sampling_rate` seconds.
2292+
`1/sampling_rate` seconds. Must be positive, or None for a
2293+
continuous-time controller called at every integration step.
22852294
throttle_range : tuple, optional
22862295
A tuple containing the minimum and maximum throttle values. Must be in the range [0, 1]. Default is (0.0, 1.0).
22872296
throttle_rate_limit : float, optional
@@ -2292,11 +2301,13 @@ def add_throttle_control(
22922301
[throttle_range[0], throttle_range[1]]. If False, a warning is issued when
22932302
throttle values exceed the range. Default is True.
22942303
initial_throttle : float, optional
2295-
Initial throttle value at the start of the simulation. Must be within
2296-
the range [throttle_range[0], throttle_range[1]]. Default is 1.0.
2304+
Initial throttle value at the start of the simulation. A value
2305+
outside [throttle_range[0], throttle_range[1]] is clamped into the
2306+
range when clamp is True, and warned about otherwise. Default is
2307+
1.0.
22972308
throttle_time_constant : float, optional
2298-
Time constant for the throttle actuator dynamics in seconds.
2299-
If None, no actuator dynamics are applied.
2309+
Time constant for the throttle actuator dynamics in seconds. Must be
2310+
non-negative. If None, no actuator dynamics are applied.
23002311
initial_observed_variables : list, optional
23012312
A list of the initial values of the variables that the controller
23022313
function manages. This list is used to initialize the

tests/unit/rocket/test_actuators.py

Lines changed: 112 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
import subprocess
23
import sys
34

@@ -455,60 +456,78 @@ def test_time_constant_iir_filter(self):
455456
assert initial_output < filtered_output < 1.0
456457

457458

458-
class TestActuatorValidation:
459-
"""Test suite for actuator parameter validation."""
460-
461-
def test_invalid_demand_rate_negative(self):
462-
"""Test that negative demand rate is rejected."""
463-
with pytest.raises(ValueError):
464-
RollActuator(demand_rate=-1)
459+
NAN = float("nan")
465460

466-
def test_invalid_range(self):
467-
"""Test that invalid range is rejected."""
468-
with pytest.raises(ValueError):
469-
RollActuator(
470-
max_roll_torque=-5
471-
) # This creates range (5, -5) which is invalid
472461

473-
def test_invalid_time_constant_negative(self):
474-
"""Test that negative time constant is rejected."""
475-
with pytest.raises(ValueError):
476-
ThrottleActuator(throttle_time_constant=-0.1)
462+
def _as_source(value):
463+
"""Render a value as source the ``-O`` subprocess can evaluate.
477464
478-
def test_invalid_rate_limit_negative(self):
479-
"""Test that negative rate limit is rejected."""
480-
with pytest.raises(ValueError):
481-
ThrustVectorActuator(gimbal_rate_limit=-1.0)
465+
``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.
469+
"""
470+
if isinstance(value, float) and math.isnan(value):
471+
return 'float("nan")'
472+
if isinstance(value, tuple):
473+
return "(" + ", ".join(_as_source(item) for item in value) + ",)"
474+
return repr(value)
475+
476+
477+
# One table, walked twice: once in-process for the message, once under ``-O``.
478+
# Keeping them in step is the point. An argument that is only rejected in the
479+
# default interpreter is not rejected, because the checks these replaced were
480+
# asserts and asserts are what ``-O`` removes.
481+
#
482+
# Each entry names the message it expects, so a case cannot pass on some other
483+
# argument's check. Every NaN case is here because NaN fails every ordered
484+
# comparison: `nan <= 0` is false just as `nan > 0` is, so a check written as the
485+
# inverted comparison accepts it while the assert it replaces rejected it.
486+
INVALID_ARGUMENTS = [
487+
(RollActuator, {"demand_rate": -1}, "demand_rate"),
488+
(RollActuator, {"demand_rate": 0}, "demand_rate"),
489+
(RollActuator, {"demand_rate": NAN}, "demand_rate"),
490+
(RollActuator, {"max_roll_torque": -5}, "actuator_range"),
491+
(ThrottleActuator, {"throttle_range": (NAN, 1.0)}, "actuator_range"),
492+
(ThrottleActuator, {"throttle_range": (0.0, NAN)}, "actuator_range"),
493+
(ThrustVectorActuator, {"gimbal_rate_limit": -1.0}, "rate_limit"),
494+
(ThrustVectorActuator, {"gimbal_rate_limit": NAN}, "rate_limit"),
495+
(ThrottleActuator, {"throttle_time_constant": -0.1}, "time_constant"),
496+
(ThrottleActuator, {"throttle_time_constant": NAN}, "time_constant"),
497+
(ThrottleActuator, {"initial_throttle": NAN}, "initial output"),
498+
# clamp is what would otherwise absorb an out-of-range initial value, and
499+
# np.clip returns NaN for NaN, so both settings have to refuse it.
500+
(ThrottleActuator, {"initial_throttle": NAN, "clamp": False}, "initial output"),
501+
]
502+
INVALID_IDS = [
503+
f"{cls.__name__}-{'-'.join(kwargs)}-{'clamped' if kwargs.get('clamp', True) else 'unclamped'}"
504+
for cls, kwargs, _ in INVALID_ARGUMENTS
505+
]
482506

483-
def test_demand_rate_none_builds_a_continuous_actuator(self):
484-
"""None is the documented continuous-time mode and must be accepted.
485507

486-
The check used to read ``demand_rate > 0 or demand_rate is None``, and
487-
Python evaluates the left operand first, so this raised TypeError and the
488-
mode could not be constructed at all.
489-
"""
490-
actuator = RollActuator(demand_rate=None)
508+
class TestActuatorValidation:
509+
"""Test suite for actuator parameter validation."""
491510

492-
assert actuator.demand_rate is None
511+
@pytest.mark.parametrize(
512+
"actuator_class, kwargs, message", INVALID_ARGUMENTS, ids=INVALID_IDS
513+
)
514+
def test_invalid_arguments_are_rejected(self, actuator_class, kwargs, message):
515+
with pytest.raises(ValueError, match=message):
516+
actuator_class(**kwargs)
493517

494518
@pytest.mark.parametrize(
495-
"actuator_class, kwargs",
496-
[
497-
(RollActuator, {"demand_rate": -1}),
498-
(RollActuator, {"max_roll_torque": -5}),
499-
(ThrottleActuator, {"throttle_time_constant": -0.1}),
500-
(ThrustVectorActuator, {"gimbal_rate_limit": -1.0}),
501-
],
519+
"actuator_class, kwargs, message", INVALID_ARGUMENTS, ids=INVALID_IDS
502520
)
503-
def test_validation_survives_optimized_mode(self, actuator_class, kwargs):
521+
def test_validation_survives_optimized_mode(self, actuator_class, kwargs, message):
504522
"""``python -O`` drops assert statements, so these must not be asserts.
505523
506524
Run in a subprocess because the flag is set at interpreter startup. Under
507525
the old bare asserts every one of these was accepted in silence.
508526
"""
527+
arguments = ", ".join(f"{k}={_as_source(v)}" for k, v in kwargs.items())
509528
source = (
510529
"from rocketpy.rocket.actuator import "
511-
f"{actuator_class.__name__} as A; A(**{kwargs!r})"
530+
f"{actuator_class.__name__} as A; A({arguments})"
512531
)
513532
result = subprocess.run(
514533
[sys.executable, "-O", "-c", source],
@@ -519,6 +538,41 @@ def test_validation_survives_optimized_mode(self, actuator_class, kwargs):
519538

520539
assert result.returncode != 0, "invalid arguments were accepted under -O"
521540
assert "ValueError" in result.stderr
541+
assert message in result.stderr
542+
543+
def test_demand_rate_none_builds_a_continuous_actuator(self):
544+
"""None is the documented continuous-time mode and must be accepted.
545+
546+
The check used to read ``demand_rate > 0 or demand_rate is None``, and
547+
Python evaluates the left operand first, so this raised TypeError and the
548+
mode could not be constructed at all.
549+
"""
550+
actuator = RollActuator(demand_rate=None)
551+
552+
assert actuator.demand_rate is None
553+
554+
@pytest.mark.parametrize(
555+
"actuator_class, kwargs",
556+
[
557+
(RollActuator, {"torque_rate_limit": 0.0}),
558+
(ThrottleActuator, {"throttle_time_constant": 0.0}),
559+
(ThrustVectorActuator, {"gimbal_rate_limit": 0.0}),
560+
],
561+
)
562+
def test_zero_is_accepted_where_the_bound_is_non_negative(
563+
self, actuator_class, kwargs
564+
):
565+
"""Zero is on the legal side of every non-negative bound.
566+
567+
Worth its own case because the fix moved these from ``x < 0`` to
568+
``not x >= 0``, and an off-by-one there would turn the documented "no
569+
dynamics" and "no rate limit" settings into errors. A zero rate limit
570+
does freeze the actuator, but that is the caller's business, and the
571+
constructor is not where that is decided.
572+
"""
573+
actuator = actuator_class(**kwargs)
574+
575+
assert actuator is not None
522576

523577

524578
class TestActuatorInitialOutput:
@@ -557,6 +611,26 @@ def test_without_clamping_it_warns_instead(self):
557611

558612
assert actuator.actuator_initial_output == 2.0
559613

614+
def test_the_warning_is_not_blamed_on_the_actuator_module(self):
615+
"""``pytest.warns`` reads the message, and the message is not the whole
616+
warning. Without a stacklevel the report points at the ``warnings.warn``
617+
line inside ``actuator.py``, which is the same line for every caller, so
618+
``-W`` filters keyed on a module and the printed location are both
619+
useless.
620+
621+
Asserting the negative rather than a specific file because the warning is
622+
raised in a base ``__init__`` reached through ``super()``, so no single
623+
stacklevel lands on user code for every actuator: 2 reaches the concrete
624+
subclass, and the dual-axis actuator adds another frame on top of that.
625+
Not blaming the base module is the part that holds for all of them.
626+
"""
627+
with pytest.warns(UserWarning, match="outside its range") as record:
628+
ThrottleActuator(
629+
throttle_range=(0.0, 1.0), initial_throttle=2.0, clamp=False
630+
)
631+
632+
assert not record[0].filename.endswith("actuator.py")
633+
560634

561635
class TestActuatorWarnings:
562636
"""Test suite for actuator warning conditions."""

0 commit comments

Comments
 (0)