Skip to content

Commit 99eef7e

Browse files
committed
Validate actuator arguments so the checks survive -O and None works
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>
1 parent 2762df1 commit 99eef7e

2 files changed

Lines changed: 116 additions & 21 deletions

File tree

rocketpy/rocket/actuator/actuator.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ def __init__(
3636
clamp : bool, optional
3737
Whether to clamp the actuator output. Default is True.
3838
actuator_initial_output : float, optional
39-
Initial output of the actuator. Default is 0.0.
39+
Initial output of the actuator. Default is 0.0. A value outside
40+
actuator_range is treated the way the output setter treats one: it is
41+
clamped when clamp is True, and warned about otherwise.
4042
actuator_time_constant : float, optional
4143
Time constant of the actuator, implemented as a discrete IIR filter. Default is None.
4244
@@ -47,29 +49,42 @@ def __init__(
4749

4850
self.name = name
4951

50-
assert demand_rate > 0 or demand_rate is None, (
51-
"demand_rate must be positive or None."
52-
)
52+
# These are argument checks rather than internal invariants, so they raise
53+
# instead of asserting: python -O drops assert statements, and a negative
54+
# time constant or rate limit would then be accepted in silence.
55+
if demand_rate is not None and demand_rate <= 0:
56+
raise ValueError("demand_rate must be positive or None.")
5357
self.demand_rate = demand_rate
5458

55-
assert actuator_range[0] <= actuator_range[1], (
56-
"actuator_range[0] must be <= actuator_range[1]."
57-
)
59+
if actuator_range[0] > actuator_range[1]:
60+
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
5861
self.actuator_range = actuator_range
5962

60-
assert actuator_rate_limit is None or actuator_rate_limit >= 0, (
61-
"actuator_rate_limit must be non-negative or None."
62-
)
63+
if actuator_rate_limit is not None and actuator_rate_limit < 0:
64+
raise ValueError("actuator_rate_limit must be non-negative or None.")
6365
self.actuator_rate_limit = actuator_rate_limit
6466

6567
self.clamp = clamp
6668

67-
assert actuator_time_constant is None or actuator_time_constant >= 0, (
68-
"actuator_time_constant must be non-negative or None."
69-
)
69+
if actuator_time_constant is not None and actuator_time_constant < 0:
70+
raise ValueError("actuator_time_constant must be non-negative or None.")
7071
self.actuator_time_constant = actuator_time_constant
7172
self._update_iir_coefficients()
7273

74+
# An initial output outside the range used to survive here and come back
75+
# on every _reset(), even though the output setter would never let the
76+
# actuator reach such a value afterwards. Treat it the way the setter
77+
# treats any other out-of-range value, so the two agree.
78+
if self.clamp:
79+
actuator_initial_output = float(
80+
np.clip(actuator_initial_output, actuator_range[0], actuator_range[1])
81+
)
82+
elif not actuator_range[0] <= actuator_initial_output <= actuator_range[1]:
83+
warnings.warn(
84+
f"Actuator '{name}' initial output {actuator_initial_output} "
85+
f"is outside its range {actuator_range}."
86+
)
87+
7388
self.actuator_initial_output = actuator_initial_output
7489
self._actuator_output = actuator_initial_output
7590

tests/unit/rocket/test_actuators.py

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import subprocess
2+
import sys
3+
14
import pytest
25

36
from rocketpy.rocket.actuator.roll import RollActuator
@@ -456,27 +459,104 @@ class TestActuatorValidation:
456459
"""Test suite for actuator parameter validation."""
457460

458461
def test_invalid_demand_rate_negative(self):
459-
"""Test that negative demand rate raises assertion error."""
460-
with pytest.raises(AssertionError):
462+
"""Test that negative demand rate is rejected."""
463+
with pytest.raises(ValueError):
461464
RollActuator(demand_rate=-1)
462465

463466
def test_invalid_range(self):
464-
"""Test that invalid range raises assertion error."""
465-
with pytest.raises(AssertionError):
467+
"""Test that invalid range is rejected."""
468+
with pytest.raises(ValueError):
466469
RollActuator(
467470
max_roll_torque=-5
468471
) # This creates range (5, -5) which is invalid
469472

470473
def test_invalid_time_constant_negative(self):
471-
"""Test that negative time constant raises assertion error."""
472-
with pytest.raises(AssertionError):
474+
"""Test that negative time constant is rejected."""
475+
with pytest.raises(ValueError):
473476
ThrottleActuator(throttle_time_constant=-0.1)
474477

475478
def test_invalid_rate_limit_negative(self):
476-
"""Test that negative rate limit raises assertion error."""
477-
with pytest.raises(AssertionError):
479+
"""Test that negative rate limit is rejected."""
480+
with pytest.raises(ValueError):
478481
ThrustVectorActuator(gimbal_rate_limit=-1.0)
479482

483+
def test_demand_rate_none_builds_a_continuous_actuator(self):
484+
"""None is the documented continuous-time mode and must be accepted.
485+
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)
491+
492+
assert actuator.demand_rate is None
493+
494+
@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+
],
502+
)
503+
def test_validation_survives_optimized_mode(self, actuator_class, kwargs):
504+
"""``python -O`` drops assert statements, so these must not be asserts.
505+
506+
Run in a subprocess because the flag is set at interpreter startup. Under
507+
the old bare asserts every one of these was accepted in silence.
508+
"""
509+
source = (
510+
"from rocketpy.rocket.actuator import "
511+
f"{actuator_class.__name__} as A; A(**{kwargs!r})"
512+
)
513+
result = subprocess.run(
514+
[sys.executable, "-O", "-c", source],
515+
capture_output=True,
516+
text=True,
517+
check=False,
518+
)
519+
520+
assert result.returncode != 0, "invalid arguments were accepted under -O"
521+
assert "ValueError" in result.stderr
522+
523+
524+
class TestActuatorInitialOutput:
525+
"""An actuator must not start outside its own range.
526+
527+
The output setter already refuses to leave the range, but the initial value
528+
bypassed it and ``_reset()`` restored that same value, so a reset actuator
529+
ended up somewhere the setter would never have put it.
530+
"""
531+
532+
def test_an_out_of_range_initial_value_is_clamped(self):
533+
actuator = ThrottleActuator(throttle_range=(0.0, 1.0), initial_throttle=2.0)
534+
535+
assert actuator.actuator_output == 1.0
536+
assert actuator.actuator_initial_output == 1.0
537+
538+
def test_the_clamped_value_survives_a_reset(self):
539+
actuator = ThrottleActuator(throttle_range=(0.0, 1.0), initial_throttle=-3.0)
540+
actuator.actuator_output = 0.5
541+
actuator._reset()
542+
543+
assert actuator.actuator_output == 0.0
544+
545+
def test_a_value_inside_the_range_is_untouched(self):
546+
actuator = ThrottleActuator(throttle_range=(0.0, 1.0), initial_throttle=0.25)
547+
548+
assert actuator.actuator_initial_output == 0.25
549+
550+
def test_without_clamping_it_warns_instead(self):
551+
# clamp=False is the documented way to let an actuator report outside its
552+
# range, so the initial value follows the setter and only warns.
553+
with pytest.warns(UserWarning, match="outside its range"):
554+
actuator = ThrottleActuator(
555+
throttle_range=(0.0, 1.0), initial_throttle=2.0, clamp=False
556+
)
557+
558+
assert actuator.actuator_initial_output == 2.0
559+
480560

481561
class TestActuatorWarnings:
482562
"""Test suite for actuator warning conditions."""

0 commit comments

Comments
 (0)