44import 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+
772class 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