Skip to content

Commit 915b09a

Browse files
authored
Merge pull request #115 from ARRC-Rocket/fix/a-non-finite-action-is-not-a-command
An action the environment cannot use is not a run the agent should lose
2 parents 5c69334 + f2b22f5 commit 915b09a

2 files changed

Lines changed: 329 additions & 14 deletions

File tree

BalloonPoppingGymEnv/envs/balloon_world.py

Lines changed: 118 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,81 @@
3030

3131
logger = logging.getLogger(__name__)
3232

33+
# How many numbers each action field holds. The environment indexes `tvc` and
34+
# `launch_inclination_heading` by position, so a wrong count is not usable
35+
# either, and an empty array passes a finiteness test on its own.
36+
ACTION_FIELD_SIZES = {
37+
"launch_inclination_heading": 2,
38+
"tvc": 2,
39+
"roll": 1,
40+
"throttle": 1,
41+
}
42+
43+
44+
# Which repetition of an unusable action gets a log line. A broken policy stays
45+
# broken, and one line per step for the rest of the horizon is its own problem.
46+
_UNUSABLE_ACTION_LOG_STEPS = frozenset({1, 10, 100, 1000})
47+
48+
49+
def _is_launch_command(action):
50+
"""Whether `action["launch"]` asks for a launch, without guessing.
51+
52+
`bool(np.nan)` is True and a two element array raises, so the truthiness of
53+
whatever arrives is not a safe question to ask directly.
54+
"""
55+
try:
56+
values = np.asarray(action["launch"]).reshape(-1)
57+
except (KeyError, TypeError, ValueError):
58+
return False
59+
if values.size != 1:
60+
return False
61+
try:
62+
value = float(values[0])
63+
except (TypeError, ValueError, OverflowError):
64+
return False
65+
# Finite first: `nan != 0` is True, so a NaN would otherwise launch.
66+
return bool(np.isfinite(value)) and value != 0.0
67+
68+
69+
def check_action(action):
70+
"""The action fields the environment cannot use, by name.
71+
72+
A policy that diverges emits NaN, and it is the most ordinary failure there
73+
is while training one. An empty list means every field is usable.
74+
75+
Public so an agent can ask the same question before returning an action:
76+
77+
if check_action(action):
78+
action = last_good_action
79+
"""
80+
return sorted(_usable_action_fields(action)[1])
81+
82+
83+
def _usable_action_fields(action):
84+
"""The fields as arrays of the right size, and the names of the rest.
85+
86+
Validating a converted copy and then handing the original to the actuators
87+
would check something other than what is used: a scalar `tvc` converts to a
88+
finite array and then fails at `action["tvc"][0]`. So the converted values
89+
are what the caller gets.
90+
"""
91+
usable = {}
92+
unusable = set()
93+
for field, size in ACTION_FIELD_SIZES.items():
94+
if field not in action:
95+
unusable.add(field)
96+
continue
97+
try:
98+
values = np.atleast_1d(np.asarray(action[field], dtype=float))
99+
except (TypeError, ValueError, OverflowError):
100+
unusable.add(field)
101+
continue
102+
if values.size != size or not np.all(np.isfinite(values)):
103+
unusable.add(field)
104+
continue
105+
usable[field] = values
106+
return usable, unusable
107+
33108

34109
def _remove_monte_carlo_workspace(directory):
35110
"""Delete a Monte Carlo scratch directory, saying so if it will not go.
@@ -148,6 +223,8 @@ def __init__(self, render_mode, parameters):
148223
self._balloon_states = np.array(np.zeros((self.balloon_parameters["num"], 6)))
149224
# (gyroX, gyroY, gyroZ, accX, accY, accZ, posX, posY, posZ, velX, velY, velZ)
150225
self._rocket_sensors = np.full(12, np.nan)
226+
# Steps whose action the environment could not use, for rate limiting.
227+
self._unusable_action_steps = 0
151228
# (posX, posY, posZ, velX, velY, velZ, e0, e1, e2, e3, w1, w2, w3)
152229
self._rocket_states = np.full(13, np.nan)
153230
# Where the next pop sweep starts. Tracked separately from
@@ -294,6 +371,8 @@ def reset(self, seed=None, options=None):
294371

295372
self._balloon_states = self._balloon_flights[:, :, 0]
296373
self._rocket_sensors = np.full(12, np.nan)
374+
# Steps whose action the environment could not use, for rate limiting.
375+
self._unusable_action_steps = 0
297376
self._rocket_states = np.full(13, np.nan)
298377
self._sweep_origin = None
299378
self.trajectories = None
@@ -325,28 +404,53 @@ def step(self, action):
325404
ground_mask = self._balloon_status[:, 0] == 0
326405
self._balloon_status[released_mask & ground_mask, 0] = 1
327406

407+
# A diverging policy emits NaN, and it is the most ordinary failure there
408+
# is while training one. A step it cannot command is not a run it should
409+
# lose, so the command is dropped and the episode carries on.
410+
commands, unusable = _usable_action_fields(action)
411+
if unusable:
412+
# Rate limited, since a policy that breaks stays broken and one line
413+
# per step for the rest of the horizon is its own denial of service.
414+
self._unusable_action_steps += 1
415+
if self._unusable_action_steps in _UNUSABLE_ACTION_LOG_STEPS:
416+
logger.warning(
417+
"Step %d: ignoring %s, which the environment cannot use "
418+
"(%d such steps so far)",
419+
self.current_step,
420+
", ".join(sorted(unusable)),
421+
self._unusable_action_steps,
422+
)
423+
328424
if not self.rocket_launched:
329425
_rocket_finished = False
330-
if action["launch"]: # Init rocket flight with first launch action
331-
self.rocket_launched = True
332-
self.__get_init_rocket_states(
333-
action["launch_inclination_heading"][0],
334-
action["launch_inclination_heading"][1],
335-
)
426+
# Refused rather than dropped, because there is no previous attitude
427+
# to fall back on. The agent can launch on any later step.
428+
if _is_launch_command(action) and "launch_inclination_heading" in commands:
429+
attitude = commands["launch_inclination_heading"]
430+
self.__get_init_rocket_states(attitude[0], attitude[1])
336431
self.initial_solution[0] = (
337432
self.current_step * self.simulation_parameters["time_step"]
338433
)
339434
self.__init_rocket_simulation()
340435
self._sweep_origin = np.asarray(self.initial_solution[1:4], dtype=float)
436+
# Last, so a failure above leaves the environment unlaunched
437+
# rather than launched with no flight for the next step to use.
438+
self.rocket_launched = True
341439
else: # Apply action to step the rocket simulation and get sensor measurements
342-
self._rocket_flight.rocket.roll_control.roll_torque = action["roll"]
343-
self._rocket_flight.rocket.thrust_vector_control.gimbal_angle_x = action[
344-
"tvc"
345-
][0]
346-
self._rocket_flight.rocket.thrust_vector_control.gimbal_angle_y = action[
347-
"tvc"
348-
][1]
349-
self._rocket_flight.rocket.throttle_control.throttle = action["throttle"]
440+
# Each actuator keeps the value it already holds when its field is
441+
# dropped, which is what a real one does between commands.
442+
if "roll" in commands:
443+
self._rocket_flight.rocket.roll_control.roll_torque = float(
444+
commands["roll"][0]
445+
)
446+
if "tvc" in commands:
447+
thrust_vector = self._rocket_flight.rocket.thrust_vector_control
448+
thrust_vector.gimbal_angle_x = float(commands["tvc"][0])
449+
thrust_vector.gimbal_angle_y = float(commands["tvc"][1])
450+
if "throttle" in commands:
451+
self._rocket_flight.rocket.throttle_control.throttle = float(
452+
commands["throttle"][0]
453+
)
350454
self._rocket_flight.step_simulation()
351455
_sensor = self._rocket_flight.sensors
352456
self._rocket_sensors[:3] = _sensor[0].measurement # gyro

tests/test_non_finite_actions.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""What the environment does with an action it cannot use.
2+
3+
A policy that diverges emits NaN, and it is the most ordinary failure there is
4+
while training one. Measured on the pin these tests were written against: a
5+
single NaN in `tvc` was harmless, the run finished with the same score as a
6+
clean one, while a NaN in `launch_inclination_heading` raised
7+
`ValueError: All components of the initial state y0 must be finite` and ended
8+
the evaluation. ActiveRocketPy #19 makes the actuator setters refuse a
9+
non-finite command too, so without this the first kind becomes the second.
10+
11+
The rule here is that a field the environment cannot use is dropped, the
12+
actuator keeps what it holds, and the episode carries on. A run is not lost to
13+
one bad step.
14+
"""
15+
16+
import logging
17+
import unittest
18+
from importlib.util import find_spec
19+
20+
import numpy as np
21+
22+
_STACK_AVAILABLE = find_spec("rocketpy") is not None
23+
24+
if _STACK_AVAILABLE:
25+
from BalloonPoppingGymEnv.envs.balloon_world import (
26+
BalloonPoppingEnv,
27+
check_action,
28+
)
29+
from BalloonPoppingGymEnv.evaluation.evaluate import load_scenario_parameters
30+
31+
SCENARIO = 0
32+
LAUNCH_STEP = 1
33+
STEPS_AFTER_LAUNCH = 8
34+
35+
36+
_CLEAN = {
37+
"launch_inclination_heading": np.array([80.0, 0.0]),
38+
"tvc": np.array([0.0, 0.0]),
39+
"roll": np.array([0.0]),
40+
"throttle": np.array([1.0]),
41+
}
42+
43+
44+
class TestWhichFieldsAreUnusable(unittest.TestCase):
45+
"""The reader, tested on its own because everything below trusts it."""
46+
47+
def test_a_clean_action_has_none(self):
48+
self.assertEqual(check_action(dict(_CLEAN)), [])
49+
50+
def test_each_field_is_found_by_name(self):
51+
for field, shape in (
52+
("launch_inclination_heading", 2),
53+
("tvc", 2),
54+
("roll", 1),
55+
("throttle", 1),
56+
):
57+
with self.subTest(field=field):
58+
action = dict(_CLEAN, **{field: np.full(shape, np.nan)})
59+
60+
self.assertEqual(check_action(action), [field])
61+
62+
def test_infinity_counts_too(self):
63+
"""`np.clip` would carry an infinity straight through, and the actuator
64+
would store it, so it is not only NaN that has to be caught."""
65+
action = dict(_CLEAN, tvc=np.array([np.inf, 0.0]))
66+
67+
self.assertEqual(check_action(action), ["tvc"])
68+
69+
def test_a_field_that_is_not_a_number_at_all(self):
70+
"""`np.asarray(..., dtype=float)` raises on these rather than giving a
71+
NaN, so they need naming separately or they reach the actuator."""
72+
for value in ("nope", None, {"x": 1}):
73+
with self.subTest(value=repr(value)):
74+
self.assertIn("roll", check_action(dict(_CLEAN, roll=value)))
75+
76+
def test_a_missing_field_is_not_usable(self):
77+
"""Every shipped agent returns all four, and `step()` indexes them, so a
78+
partial action passing the helper and failing in the environment is the
79+
split this exists to remove."""
80+
self.assertEqual(
81+
check_action({"roll": np.array([0.0])}),
82+
["launch_inclination_heading", "throttle", "tvc"],
83+
)
84+
85+
def test_the_wrong_number_of_values_is_not_usable(self):
86+
"""An empty array passes a finiteness test on its own, and `step()`
87+
indexes `tvc[1]`."""
88+
for field, value in (
89+
("tvc", np.array([])),
90+
("tvc", np.zeros(3)),
91+
("tvc", 0.0),
92+
("launch_inclination_heading", np.array([80.0])),
93+
):
94+
with self.subTest(field=field, size=np.size(value)):
95+
self.assertIn(field, check_action(dict(_CLEAN, **{field: value})))
96+
97+
98+
@unittest.skipUnless(_STACK_AVAILABLE, "simulation stack not installed")
99+
class TestAnActionTheEnvironmentCannotUse(unittest.TestCase):
100+
@classmethod
101+
def setUpClass(cls):
102+
cls.parameters, _given = load_scenario_parameters(SCENARIO)
103+
104+
def _fresh(self):
105+
env = BalloonPoppingEnv(render_mode=None, parameters=self.parameters)
106+
env.reset(seed=self.parameters["scenario"]["random_seed"])
107+
return env
108+
109+
@staticmethod
110+
def _action(env, launch=False):
111+
action = env.action_space.sample()
112+
action["launch"] = np.array(int(launch), dtype=action["launch"].dtype)
113+
action["launch_inclination_heading"] = np.array([80.0, 0.0], dtype=np.float64)
114+
action["throttle"] = np.ones_like(action["throttle"])
115+
action["tvc"] = np.zeros_like(action["tvc"])
116+
action["roll"] = np.zeros_like(action["roll"])
117+
return action
118+
119+
def _flown(self, env, steps, corrupt=None):
120+
"""Launch, then step, optionally corrupting one field throughout."""
121+
for index in range(LAUNCH_STEP + steps):
122+
action = self._action(env, launch=index == LAUNCH_STEP - 1)
123+
if corrupt and index >= LAUNCH_STEP:
124+
action[corrupt] = np.full_like(
125+
np.asarray(action[corrupt], dtype=float), np.nan
126+
)
127+
env.step(action)
128+
return env
129+
130+
def test_a_control_field_of_nans_does_not_end_the_run(self):
131+
"""The case ActiveRocketPy #19 turns into a ValueError without this."""
132+
for field in ("tvc", "roll", "throttle"):
133+
with self.subTest(field=field):
134+
env = self._flown(self._fresh(), STEPS_AFTER_LAUNCH, corrupt=field)
135+
136+
self.assertTrue(env.rocket_launched)
137+
self.assertEqual(
138+
len(env.trajectories), LAUNCH_STEP + STEPS_AFTER_LAUNCH
139+
)
140+
141+
def test_the_actuator_keeps_the_value_it_had(self):
142+
"""Dropping the command has to mean keeping the last one, not zeroing
143+
it, or a single NaN would silently cut the throttle."""
144+
env = self._fresh()
145+
for index in range(4):
146+
env.step(self._action(env, launch=index == LAUNCH_STEP - 1))
147+
held = float(env._rocket_flight.rocket.throttle_control.throttle)
148+
149+
action = self._action(env)
150+
action["throttle"] = np.full_like(
151+
np.asarray(action["throttle"], dtype=float), np.nan
152+
)
153+
env.step(action)
154+
155+
self.assertEqual(
156+
float(env._rocket_flight.rocket.throttle_control.throttle), held
157+
)
158+
159+
def test_a_launch_attitude_of_nans_does_not_launch(self):
160+
"""There is no previous attitude to fall back on, so the launch is
161+
refused rather than dropped. Measured without this: the flight is built
162+
from a NaN quaternion and raises out of the integrator."""
163+
env = self._fresh()
164+
action = self._action(env, launch=True)
165+
action["launch_inclination_heading"] = np.array([np.nan, 0.0])
166+
167+
env.step(action)
168+
169+
self.assertFalse(env.rocket_launched)
170+
171+
def test_the_agent_can_launch_on_a_later_step(self):
172+
"""The half that stops the refusal above being a way to lose the run."""
173+
env = self._fresh()
174+
broken = self._action(env, launch=True)
175+
broken["launch_inclination_heading"] = np.array([np.nan, 0.0])
176+
env.step(broken)
177+
178+
env.step(self._action(env, launch=True))
179+
180+
self.assertTrue(env.rocket_launched)
181+
182+
def test_the_dropped_field_is_named_in_the_log(self):
183+
"""A competitor reading the output has to be able to tell that a command
184+
went nowhere, and which one."""
185+
env = self._fresh()
186+
for index in range(3):
187+
env.step(self._action(env, launch=index == LAUNCH_STEP - 1))
188+
action = self._action(env)
189+
action["tvc"] = np.full_like(np.asarray(action["tvc"], dtype=float), np.nan)
190+
191+
with self.assertLogs(
192+
"BalloonPoppingGymEnv.envs.balloon_world", logging.WARNING
193+
) as caught:
194+
env.step(action)
195+
196+
self.assertIn("tvc", "".join(caught.output))
197+
198+
def test_a_clean_run_logs_nothing_and_scores_the_same(self):
199+
"""The half that stops all of this being satisfied by dropping
200+
everything."""
201+
env = self._fresh()
202+
logger = logging.getLogger("BalloonPoppingGymEnv.envs.balloon_world")
203+
with self.assertNoLogs(logger, logging.WARNING):
204+
for index in range(LAUNCH_STEP + STEPS_AFTER_LAUNCH):
205+
env.step(self._action(env, launch=index == LAUNCH_STEP - 1))
206+
207+
self.assertTrue(env.rocket_launched)
208+
209+
210+
if __name__ == "__main__":
211+
unittest.main()

0 commit comments

Comments
 (0)