File size: 6,622 Bytes
2eeee5a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | """Local terrain belief map (ported from hackathon-everest's `mapping.py`).
This is the half of the problem the RL repo was missing. Without it the policy sees only
what is under its feet *right now*; with it, each foot's observation accumulates into a
spatial memory that the OTHER foot can read before it lands. That cross-foot transfer is
everest's central claim.
Notably, everest's own benchmark could not measure whether the map helped: decomposing its
7 unsafe transfers showed all 7 were stance-capacity failures, which the bilateral gate
catches and the map has nothing to do with. Here the map is a separate observation channel
with its own ablation flag, so its contribution is directly measurable for the first time.
Differences from everest's version, all forced by running inside a 50 Hz JAX training loop:
* **Two channels, not six.** everest maps six quantities; only support and depth reach the
policy, so mapping the rest would cost memory for nothing.
* **Coarser.** 40x10 cells at 0.2 m over the corridor, versus everest's 40x40 at 0.05 m.
The map lives per-environment in `state.info`, so at 8192 envs this is ~52 MB; everest's
resolution would be ~26x that.
* **Reflect-tiled**, matching terrain.sample, so map and terrain stay in the same frame.
* Update is the same Gaussian-kernel Kalman step: a measurement at one point updates
neighbours with weight falling off over a correlation length.
"""
from __future__ import annotations
from typing import NamedTuple
import jax
import jax.numpy as jnp
from snow_rl.terrain import DEFAULT_CONFIG, TerrainConfig, _reflect
MAP_ROWS = 40 # along the corridor
MAP_COLS = 10 # across it
CORRELATION_LENGTH_M = 0.35
MIN_WEIGHT = 0.01 # below this a cell is left untouched
# Priors, matching everest's mapping.py defaults where they overlap.
PRIOR_SUPPORT_N = 330.0
PRIOR_SUPPORT_STD_N = 190.0
PRIOR_DEPTH_M = 0.08
PRIOR_DEPTH_STD_M = 0.07
SUPPORT_NORM_N = 900.0
DEPTH_NORM_M = 0.25
# Where the policy reads the map: offsets ahead of each foot, along the corridor.
LOOKAHEAD_M = (0.0, 0.25, 0.50)
READOUT_PER_FOOT = len(LOOKAHEAD_M) * 4 # mean+std for support and depth
READOUT_SIZE = 2 * READOUT_PER_FOOT # both feet
class BeliefMap(NamedTuple):
"""Per-environment spatial memory. Leading axis is the env batch under vmap."""
support_mean: jax.Array # (MAP_ROWS, MAP_COLS) N
support_var: jax.Array
depth_mean: jax.Array # m
depth_var: jax.Array
def init() -> BeliefMap:
shape = (MAP_ROWS, MAP_COLS)
return BeliefMap(
support_mean=jnp.full(shape, PRIOR_SUPPORT_N),
support_var=jnp.full(shape, PRIOR_SUPPORT_STD_N**2),
depth_mean=jnp.full(shape, PRIOR_DEPTH_M),
depth_var=jnp.full(shape, PRIOR_DEPTH_STD_M**2),
)
def _fractional_cell(xy_m: jax.Array, cfg: TerrainConfig):
"""World position -> fractional cell index, reflect-tiled.
Everything in this module works in CELL space and goes through this one function, so
`update` and `readout` cannot disagree. They previously did: update measured distances
with a periodic wrap while readout reflected, and the two used different half-cell
offsets. Beyond the corridor edge the map was written to one cell and read from another,
so a readout returned the untouched prior.
"""
fx = _reflect((xy_m[..., 0] + cfg.length_m / 2) / (cfg.length_m / MAP_ROWS) - 0.5, MAP_ROWS)
fy = _reflect((xy_m[..., 1] + cfg.width_m / 2) / (cfg.width_m / MAP_COLS) - 0.5, MAP_COLS)
return fx, fy
def _kalman(mean, var, weight, observed, observed_std):
"""Gaussian-kernel Kalman update: distant cells see a larger effective observation
variance, so a measurement informs its neighbourhood and not the whole map."""
effective_var = observed_std**2 / jnp.maximum(weight, 1e-6)
gain = jnp.where(weight >= MIN_WEIGHT, var / (var + effective_var), 0.0)
return mean + gain * (observed - mean), (1.0 - gain) * var
def update(
belief: BeliefMap,
xy_m: jax.Array, # (2,) where the measurement was taken
support_n: jax.Array, # ()
support_std_n: jax.Array,
depth_m: jax.Array,
depth_std_m: jax.Array,
cfg: TerrainConfig = DEFAULT_CONFIG,
) -> BeliefMap:
"""Fuse one foot's estimate into the map."""
fx, fy = _fractional_cell(xy_m, cfg)
rows = jnp.arange(MAP_ROWS)[:, None]
cols = jnp.arange(MAP_COLS)[None, :]
dx = (rows - fx) * (cfg.length_m / MAP_ROWS)
dy = (cols - fy) * (cfg.width_m / MAP_COLS)
weight = jnp.exp(-(dx**2 + dy**2) / (2.0 * CORRELATION_LENGTH_M**2))
sm, sv = _kalman(belief.support_mean, belief.support_var, weight,
support_n, jnp.maximum(support_std_n, 1.0))
dm, dv = _kalman(belief.depth_mean, belief.depth_var, weight,
depth_m, jnp.maximum(depth_std_m, 1e-3))
return BeliefMap(sm, sv, jnp.clip(dm, 0.0, DEPTH_NORM_M), dv)
def _index(xy_m: jax.Array, cfg: TerrainConfig):
"""Nearest cell to a world position. Shares _fractional_cell with `update`."""
fx, fy = _fractional_cell(xy_m, cfg)
return (jnp.clip(jnp.round(fx), 0, MAP_ROWS - 1).astype(int),
jnp.clip(jnp.round(fy), 0, MAP_COLS - 1).astype(int))
def readout(belief: BeliefMap, feet_xy_m: jax.Array,
cfg: TerrainConfig = DEFAULT_CONFIG) -> jax.Array:
"""What the policy sees: belief ahead of each foot, normalised.
Returns (READOUT_SIZE,) laid out per foot, per lookahead offset, as
[support_mean, support_std, depth_mean, depth_std].
"""
out = []
for foot in range(feet_xy_m.shape[0]):
for ahead in LOOKAHEAD_M:
probe = feet_xy_m[foot] + jnp.array([ahead, 0.0])
i, j = _index(probe, cfg)
out.extend([
belief.support_mean[i, j] / SUPPORT_NORM_N,
jnp.sqrt(belief.support_var[i, j]) / SUPPORT_NORM_N,
belief.depth_mean[i, j] / DEPTH_NORM_M,
jnp.sqrt(belief.depth_var[i, j]) / DEPTH_NORM_M,
])
return jnp.stack(out)
def lower_confidence_support_n(belief: BeliefMap, xy_m: jax.Array,
cfg: TerrainConfig = DEFAULT_CONFIG,
sigmas: float = 2.0) -> jax.Array:
"""Conservative support at a point: mean minus `sigmas` standard deviations.
everest ranks candidate footholds on this rather than the mean, so uncertainty costs a
candidate rather than being averaged away.
"""
i, j = _index(xy_m, cfg)
return belief.support_mean[i, j] - sigmas * jnp.sqrt(belief.support_var[i, j])
|