Skip to content

Commit 473447d

Browse files
authored
Merge pull request #23 from ARRC-Rocket/develop
REL: v1.13 hotfix, actuator argument validation (#19)
2 parents 435f58d + 6ec4a2c commit 473447d

4 files changed

Lines changed: 636 additions & 73 deletions

File tree

CHANGELOG.md

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

3737
### Fixed
3838

39+
- BUG: actuator argument checks survive `python -O`, a non-finite command is
40+
refused at the setter rather than reaching the integrator, and an
41+
`add_*_control` call now hands the controller the same sampling rate the
42+
actuator kept [#19](https://github.com/ARRC-Rocket/ActiveRocketPy/pull/19)
43+
3944
## [v1.13.0] - 2026-07-21
4045

4146
### Added

rocketpy/rocket/actuator/actuator.py

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,71 @@
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+
``bool`` is refused with them. YAML reads ``yes`` and ``on`` as ``True``, and
22+
``float(True)`` is 1.0, so a sampling rate written that way became 1 Hz with
23+
nothing said.
24+
"""
25+
if isinstance(value, bool):
26+
raise ValueError(f"{description} must be a number, not a boolean.")
27+
try:
28+
number = float(value)
29+
except (TypeError, ValueError, OverflowError) as error:
30+
raise ValueError(f"{description} must be a number.") from error
31+
if not np.isfinite(number):
32+
raise ValueError(f"{description} must be a finite number.")
33+
return number
34+
35+
36+
def _positive_or_none(value, description):
37+
"""An optional number that has to be finite and greater than zero."""
38+
if value is None:
39+
return None
40+
number = _finite_or_raise(value, description)
41+
if number <= 0:
42+
raise ValueError(f"{description} must be positive or None.")
43+
return number
44+
45+
46+
def _non_negative_or_none(value, description):
47+
"""An optional number that has to be finite and not below zero."""
48+
if value is None:
49+
return None
50+
number = _finite_or_raise(value, description)
51+
if number < 0:
52+
raise ValueError(f"{description} must be non-negative or None.")
53+
return number
54+
55+
56+
def _two_numbers_or_raise(value, description):
57+
"""A pair of numbers. Infinite is allowed here: it means unbounded."""
58+
try:
59+
lower, upper = value
60+
except (TypeError, ValueError) as error:
61+
raise ValueError(f"{description} must be exactly two numbers.") from error
62+
numbers = []
63+
for endpoint, where in ((lower, 0), (upper, 1)):
64+
# Not float(), which takes "0" and would leave a YAML string range half
65+
# converted while the rest of the config kept its strings.
66+
if isinstance(endpoint, bool) or not isinstance(endpoint, (int, float)):
67+
raise ValueError(f"{description}[{where}] must be a number.")
68+
numbers.append(float(endpoint))
69+
return numbers[0], numbers[1]
70+
71+
772
class Actuator(ABC):
873
"""Abstract class used to define actuators.
974
@@ -36,7 +101,9 @@ def __init__(
36101
clamp : bool, optional
37102
Whether to clamp the actuator output. Default is True.
38103
actuator_initial_output : float, optional
39-
Initial output of the actuator. Default is 0.0.
104+
Initial output of the actuator. Default is 0.0. A value outside
105+
actuator_range is treated the way the output setter treats one: it is
106+
clamped when clamp is True, and warned about otherwise.
40107
actuator_time_constant : float, optional
41108
Time constant of the actuator, implemented as a discrete IIR filter. Default is None.
42109
@@ -47,29 +114,75 @@ def __init__(
47114

48115
self.name = name
49116

50-
assert demand_rate > 0 or demand_rate is None, (
51-
"demand_rate must be positive or None."
52-
)
53-
self.demand_rate = demand_rate
117+
# These are argument checks rather than internal invariants, so they raise
118+
# instead of asserting: python -O drops assert statements, and a negative
119+
# time constant or rate limit would then be accepted in silence.
120+
# Finite first, then the bound, so each check says one thing. Written
121+
# as a negated comparison these accepted NaN, which fails every ordered
122+
# comparison, and infinity, which passes them: a demand rate of infinity
123+
# makes the sampling period zero, which drives the IIR coefficient to
124+
# zero and freezes the output.
125+
#
126+
# The range endpoints are deliberately not put through this. The base
127+
# default really is (-inf, inf), meaning an actuator with no range.
128+
self.demand_rate = _positive_or_none(demand_rate, "demand_rate")
54129

55-
assert actuator_range[0] <= actuator_range[1], (
56-
"actuator_range[0] must be <= actuator_range[1]."
57-
)
58-
self.actuator_range = actuator_range
130+
# A range read out of a config file arrives as anything. Without the
131+
# shape and number checks, three endpoints drop the third in silence and
132+
# string endpoints fail inside np.clip with a ufunc loop error.
133+
lower, upper = _two_numbers_or_raise(actuator_range, "actuator_range")
134+
if not lower <= upper:
135+
raise ValueError("actuator_range[0] must be <= actuator_range[1].")
136+
self.actuator_range = (lower, upper)
137+
actuator_range = self.actuator_range
59138

60-
assert actuator_rate_limit is None or actuator_rate_limit >= 0, (
61-
"actuator_rate_limit must be non-negative or None."
139+
self.actuator_rate_limit = _non_negative_or_none(
140+
actuator_rate_limit, "actuator_rate_limit"
62141
)
63-
self.actuator_rate_limit = actuator_rate_limit
64142

65143
self.clamp = clamp
66144

67-
assert actuator_time_constant is None or actuator_time_constant >= 0, (
68-
"actuator_time_constant must be non-negative or None."
145+
self.actuator_time_constant = _non_negative_or_none(
146+
actuator_time_constant, "actuator_time_constant"
69147
)
70-
self.actuator_time_constant = actuator_time_constant
148+
149+
# Neither is implemented for a continuous actuator, and both were
150+
# accepted with a warning. Asking for a 0.1 s lag and getting an
151+
# instant response is worth stopping for rather than mentioning.
152+
if self.demand_rate is None:
153+
for option, given in (
154+
("actuator_rate_limit", self.actuator_rate_limit),
155+
("actuator_time_constant", self.actuator_time_constant),
156+
):
157+
if given:
158+
raise ValueError(
159+
f"{option} needs a demand_rate: it is not applied to a "
160+
f"continuous actuator."
161+
)
162+
71163
self._update_iir_coefficients()
72164

165+
# An initial output outside the range used to survive here and come back
166+
# on every _reset(), even though the output setter would never let the
167+
# actuator reach such a value afterwards. Treat it the way the setter
168+
# treats any other out-of-range value, so the two agree.
169+
# Refused before clamping: np.clip propagates NaN, so clamping would
170+
# store it and _reset() would restore it, and there is no direction to
171+
# clamp it towards in any case.
172+
actuator_initial_output = _finite_or_raise(
173+
actuator_initial_output, f"Actuator '{name}' initial output"
174+
)
175+
if self.clamp:
176+
actuator_initial_output = float(
177+
np.clip(actuator_initial_output, actuator_range[0], actuator_range[1])
178+
)
179+
elif not actuator_range[0] <= actuator_initial_output <= actuator_range[1]:
180+
warnings.warn(
181+
f"Actuator '{name}' initial output {actuator_initial_output} "
182+
f"is outside its range {actuator_range}.",
183+
stacklevel=2,
184+
)
185+
73186
self.actuator_initial_output = actuator_initial_output
74187
self._actuator_output = actuator_initial_output
75188

@@ -82,9 +195,12 @@ def _update_iir_coefficients(self):
82195

83196
if self.actuator_time_constant is not None and self.actuator_time_constant > 0:
84197
if self.demand_rate is not None:
85-
demand_period = 1.0 / self.demand_rate
86-
self._alpha = demand_period / (
87-
self.actuator_time_constant + demand_period
198+
# Algebraically Ts / (tau + Ts) with Ts = 1 / demand_rate,
199+
# written without forming Ts. One expression instead of two,
200+
# and the two agree to 2.2e-16 over the range of time
201+
# constants and rates a real actuator uses.
202+
self._alpha = 1.0 / (
203+
1.0 + self.actuator_time_constant * self.demand_rate
88204
)
89205
else:
90206
warnings.warn(
@@ -111,6 +227,18 @@ def actuator_output(self, value):
111227
-------
112228
None
113229
"""
230+
# The same rule as the initial output, so the two agree. It used to
231+
# reject a NaN handed to the constructor and accept one handed to the
232+
# setter on the next timestep: np.clip returns NaN for NaN, and both
233+
# range comparisons are false for NaN, so neither branch noticed and it
234+
# was stored.
235+
#
236+
# This is the path an agent writes to. BalloonPoppingChallenge assigns
237+
# its actions straight into these setters, and a policy that goes
238+
# unstable emits NaN, which from here reaches the forces, the moments
239+
# and the integrator state.
240+
value = _finite_or_raise(value, f"Actuator '{self.name}' output")
241+
114242
# Apply first-order IIR actuator dynamics
115243
value = self._alpha * value + (1 - self._alpha) * self._actuator_output
116244

0 commit comments

Comments
 (0)