|
30 | 30 |
|
31 | 31 | logger = logging.getLogger(__name__) |
32 | 32 |
|
| 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 | + |
33 | 108 |
|
34 | 109 | def _remove_monte_carlo_workspace(directory): |
35 | 110 | """Delete a Monte Carlo scratch directory, saying so if it will not go. |
@@ -148,6 +223,8 @@ def __init__(self, render_mode, parameters): |
148 | 223 | self._balloon_states = np.array(np.zeros((self.balloon_parameters["num"], 6))) |
149 | 224 | # (gyroX, gyroY, gyroZ, accX, accY, accZ, posX, posY, posZ, velX, velY, velZ) |
150 | 225 | 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 |
151 | 228 | # (posX, posY, posZ, velX, velY, velZ, e0, e1, e2, e3, w1, w2, w3) |
152 | 229 | self._rocket_states = np.full(13, np.nan) |
153 | 230 | # Where the next pop sweep starts. Tracked separately from |
@@ -294,6 +371,8 @@ def reset(self, seed=None, options=None): |
294 | 371 |
|
295 | 372 | self._balloon_states = self._balloon_flights[:, :, 0] |
296 | 373 | 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 |
297 | 376 | self._rocket_states = np.full(13, np.nan) |
298 | 377 | self._sweep_origin = None |
299 | 378 | self.trajectories = None |
@@ -325,28 +404,53 @@ def step(self, action): |
325 | 404 | ground_mask = self._balloon_status[:, 0] == 0 |
326 | 405 | self._balloon_status[released_mask & ground_mask, 0] = 1 |
327 | 406 |
|
| 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 | + |
328 | 424 | if not self.rocket_launched: |
329 | 425 | _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]) |
336 | 431 | self.initial_solution[0] = ( |
337 | 432 | self.current_step * self.simulation_parameters["time_step"] |
338 | 433 | ) |
339 | 434 | self.__init_rocket_simulation() |
340 | 435 | 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 |
341 | 439 | 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 | + ) |
350 | 454 | self._rocket_flight.step_simulation() |
351 | 455 | _sensor = self._rocket_flight.sensors |
352 | 456 | self._rocket_sensors[:3] = _sensor[0].measurement # gyro |
|
0 commit comments