Skip to content

Commit 2fa75e6

Browse files
committed
Three ways a mistyped config reaches something worse than an error
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>
1 parent 36bda41 commit 2fa75e6

3 files changed

Lines changed: 138 additions & 19 deletions

File tree

rocketpy/rocket/actuator/actuator.py

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

4949

50+
def _two_numbers_or_raise(value, description):
51+
"""A pair of numbers. Infinite is allowed here: it means unbounded."""
52+
try:
53+
lower, upper = value
54+
except (TypeError, ValueError) as error:
55+
raise ValueError(f"{description} must be exactly two numbers.") from error
56+
numbers = []
57+
for endpoint, where in ((lower, 0), (upper, 1)):
58+
# Not float(), which takes "0" and would leave a YAML string range half
59+
# converted while the rest of the config kept its strings.
60+
if isinstance(endpoint, bool) or not isinstance(endpoint, (int, float)):
61+
raise ValueError(f"{description}[{where}] must be a number.")
62+
numbers.append(float(endpoint))
63+
return numbers[0], numbers[1]
64+
65+
5066
class Actuator(ABC):
5167
"""Abstract class used to define actuators.
5268
@@ -105,9 +121,14 @@ def __init__(
105121
# default really is (-inf, inf), meaning an actuator with no range.
106122
self.demand_rate = _positive_or_none(demand_rate, "demand_rate")
107123

108-
if not actuator_range[0] <= actuator_range[1]:
124+
# A range read out of a config file arrives as anything. Without the
125+
# shape and number checks, three endpoints drop the third in silence and
126+
# string endpoints fail inside np.clip with a ufunc loop error.
127+
lower, upper = _two_numbers_or_raise(actuator_range, "actuator_range")
128+
if not lower <= upper:
109129
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
110-
self.actuator_range = actuator_range
130+
self.actuator_range = (lower, upper)
131+
actuator_range = self.actuator_range
111132

112133
self.actuator_rate_limit = _non_negative_or_none(
113134
actuator_rate_limit, "actuator_rate_limit"
@@ -118,6 +139,21 @@ def __init__(
118139
self.actuator_time_constant = _non_negative_or_none(
119140
actuator_time_constant, "actuator_time_constant"
120141
)
142+
143+
# Neither is implemented for a continuous actuator, and both were
144+
# accepted with a warning. Asking for a 0.1 s lag and getting an
145+
# instant response is worth stopping for rather than mentioning.
146+
if self.demand_rate is None:
147+
for option, given in (
148+
("actuator_rate_limit", self.actuator_rate_limit),
149+
("actuator_time_constant", self.actuator_time_constant),
150+
):
151+
if given:
152+
raise ValueError(
153+
f"{option} needs a demand_rate: it is not applied to a "
154+
f"continuous actuator."
155+
)
156+
121157
self._update_iir_coefficients()
122158

123159
# An initial output outside the range used to survive here and come back

rocketpy/rocket/rocket.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2075,13 +2075,6 @@ def add_thrust_vector_control(
20752075
"Only one thrust_vector_control per rocket is currently supported. "
20762076
+ "Overwriting previous thrust_vector_control and controllers."
20772077
)
2078-
self._controllers = [
2079-
controller
2080-
for controller in self._controllers
2081-
if not isinstance(
2082-
controller.interactive_objects, ThrustVectorActuator2D
2083-
)
2084-
]
20852078

20862079
thrust_vector_control = ThrustVectorActuator2D(
20872080
name=name,
@@ -2113,6 +2106,13 @@ def add_thrust_vector_control(
21132106
initial_observed_variables=initial_observed_variables,
21142107
name=controller_name,
21152108
)
2109+
# Removed only once both halves are built, so a rejected argument on a
2110+
# second call leaves the rocket as it was.
2111+
self._controllers = [
2112+
controller
2113+
for controller in self._controllers
2114+
if not isinstance(controller.interactive_objects, ThrustVectorActuator2D)
2115+
]
21162116
self.thrust_vector_control = thrust_vector_control
21172117
self._add_controllers(_controller)
21182118
if return_controller:
@@ -2224,11 +2224,6 @@ def add_roll_control(
22242224
"Only one roll control per rocket is currently supported. "
22252225
+ "Overwriting previous roll control and controllers."
22262226
)
2227-
self._controllers = [
2228-
controller
2229-
for controller in self._controllers
2230-
if not isinstance(controller.interactive_objects, RollActuator)
2231-
]
22322227

22332228
roll_control = RollActuator(
22342229
name=name,
@@ -2247,6 +2242,13 @@ def add_roll_control(
22472242
initial_observed_variables=initial_observed_variables,
22482243
name=controller_name,
22492244
)
2245+
# Removed only once both halves are built, so a rejected argument on a
2246+
# second call leaves the rocket as it was.
2247+
self._controllers = [
2248+
controller
2249+
for controller in self._controllers
2250+
if not isinstance(controller.interactive_objects, RollActuator)
2251+
]
22502252
self.roll_control = roll_control
22512253
self._add_controllers(_controller)
22522254
if return_controller:
@@ -2360,11 +2362,6 @@ def add_throttle_control(
23602362
"Only one throttle control per rocket is currently supported. "
23612363
+ "Overwriting previous throttle control and controllers."
23622364
)
2363-
self._controllers = [
2364-
controller
2365-
for controller in self._controllers
2366-
if not isinstance(controller.interactive_objects, ThrottleActuator)
2367-
]
23682365

23692366
throttle_control = ThrottleActuator(
23702367
name=name,
@@ -2385,6 +2382,13 @@ def add_throttle_control(
23852382
name=controller_name,
23862383
)
23872384

2385+
# Removed only once both halves are built, so a rejected argument on a
2386+
# second call leaves the rocket as it was.
2387+
self._controllers = [
2388+
controller
2389+
for controller in self._controllers
2390+
if not isinstance(controller.interactive_objects, ThrottleActuator)
2391+
]
23882392
self.throttle_control = throttle_control
23892393
self._add_controllers(_controller)
23902394

tests/unit/rocket/test_actuators.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,43 @@ def test_the_two_forms_agree_where_both_work(self, demand_rate, time_constant):
721721
)
722722

723723

724+
class TestAMisspeltRange:
725+
"""A range read out of a config file arrives as anything.
726+
727+
Three endpoints dropped the third in silence, one raised IndexError, and
728+
string endpoints failed inside np.clip with a ufunc loop error.
729+
"""
730+
731+
@pytest.mark.parametrize(
732+
"limits", [(0.0,), (0.0, 1.0, 2.0), ("0", "1"), 1.0, None, (True, False)]
733+
)
734+
def test_it_is_refused_at_the_boundary(self, limits):
735+
with pytest.raises(ValueError, match="actuator_range"):
736+
ThrottleActuator(throttle_range=limits)
737+
738+
@pytest.mark.parametrize("limits", [(0.0, 1.0), (0, 1), [0.0, 1.0]])
739+
def test_the_spellings_that_work_still_work(self, limits):
740+
assert ThrottleActuator(throttle_range=limits).actuator_range == (0.0, 1.0)
741+
742+
743+
class TestDynamicsNeedADemandRate:
744+
"""Neither is implemented for a continuous actuator.
745+
746+
Both were accepted with a warning, so asking for a 0.1 s lag gave an instant
747+
response and said so in a line that a simulation log buries.
748+
"""
749+
750+
@pytest.mark.parametrize(
751+
"option", [{"throttle_time_constant": 0.1}, {"throttle_rate_limit": 0.5}]
752+
)
753+
def test_asking_for_one_without_a_rate_is_refused(self, option):
754+
with pytest.raises(ValueError, match="needs a demand_rate"):
755+
ThrottleActuator(demand_rate=None, **option)
756+
757+
def test_a_continuous_actuator_on_its_own_still_builds(self):
758+
assert ThrottleActuator(demand_rate=None).demand_rate is None
759+
760+
724761
def _no_op_controller(time, sampling_rate, state, state_history, observed, interactive): # pylint: disable=unused-argument
725762
"""A controller that commands nothing, so these tests are about the wiring."""
726763
return None
@@ -787,3 +824,45 @@ def test_the_controller_rate_is_usable_as_a_time_step(self, calisto, adder, extr
787824
controller = calisto._controllers[-1]
788825

789826
assert 1 / controller.sampling_rate == pytest.approx(0.01)
827+
828+
829+
class TestAFailedReplacementLeavesTheRocketAlone:
830+
"""The old controller used to go before the new one was built.
831+
832+
A rejected argument on a second call then left the rocket carrying an
833+
actuator that simulation would never call, with nothing said.
834+
"""
835+
836+
ADDERS = [
837+
("add_roll_control", {"max_roll_torque": 10.0}),
838+
("add_throttle_control", {}),
839+
("add_thrust_vector_control", {"max_gimbal_angle": 5.0}),
840+
]
841+
842+
@pytest.mark.parametrize("adder, extra", ADDERS)
843+
def test_a_rejected_second_call_keeps_the_first_controller(
844+
self, calisto, adder, extra
845+
):
846+
getattr(calisto, adder)(
847+
controller_function=_no_op_controller, sampling_rate=100, **extra
848+
)
849+
before = list(calisto._controllers)
850+
851+
with pytest.raises(ValueError):
852+
getattr(calisto, adder)(
853+
controller_function=_no_op_controller, sampling_rate=-1, **extra
854+
)
855+
856+
assert calisto._controllers == before
857+
858+
@pytest.mark.parametrize("adder, extra", ADDERS)
859+
def test_an_accepted_second_call_still_replaces(self, calisto, adder, extra):
860+
getattr(calisto, adder)(
861+
controller_function=_no_op_controller, sampling_rate=100, **extra
862+
)
863+
getattr(calisto, adder)(
864+
controller_function=_no_op_controller, sampling_rate=50, **extra
865+
)
866+
867+
assert len(calisto._controllers) == 1
868+
assert calisto._controllers[0].sampling_rate == 50.0

0 commit comments

Comments
 (0)