| """Snow locomotion environment (Step 4). |
| |
| Wraps MuJoCo Playground's G1 joystick env. Three changes, nothing else: |
| |
| 1. **Snow forces.** Each step, the per-environment depth field is sampled at both feet and a |
| sinkage + ploughing force is written into `data.xfrc_applied`. The MuJoCo ground stays a |
| flat plane representing the hard substrate -- snow is never geometry (see terrain.py). |
| 2. **Observations.** The 8 estimator values plus a 3-step history are appended to the actor's |
| `state` observation. The critic's `privileged_state` additionally receives true depth and |
| true bearing per foot -- training only, discarded at deployment. |
| 3. **Rewards.** Three terms on top of Playground's stock G1 terms, all scaled by confidence. |
| |
| The design rule, from the brief: **the reward reads the estimate, never the truth.** Truth is |
| permitted only as the corruption function's input, in the critic, and in physics-consequence |
| terms (stumble). If the clearance target read true depth, the policy could succeed while |
| ignoring the sensor entirely and the experiment would prove nothing. |
| |
| Terrain is stored as a shared BANK of pre-generated fields with a per-environment index, |
| rather than a field per environment. A field in `state.info` would be carried through every |
| rollout transition -- 160x40 floats x 8192 envs x unroll 20 is several GB. The bank costs |
| ~1.6 MB total and an int per environment. |
| """ |
| from __future__ import annotations |
|
|
| from typing import Any |
|
|
| import jax |
| import jax.numpy as jnp |
| import numpy as np |
| from ml_collections import config_dict |
| from mujoco import mjx |
| from mujoco_playground._src import mjx_env |
| from mujoco_playground._src.locomotion.g1 import joystick |
|
|
| from snow_rl import belief, estimator, sinkage, support |
| from snow_rl.terrain import DEFAULT_CONFIG as TERRAIN_CFG |
| from snow_rl.terrain import SnowField, batched_generate, sample |
|
|
| TERRAIN_BANK_SIZE = 64 |
| HISTORY_STEPS = 3 |
| CLEARANCE_MARGIN_M = 0.05 |
| CLEARANCE_TOLERANCE = 0.01 |
| FLAT_DEPTH_THRESHOLD_M = 0.03 |
| FLAT_NOMINAL_CLEARANCE_M = 0.07 |
| |
| |
| |
| |
| |
| |
| STUMBLE_PLOUGH_N = 15.0 |
| |
| |
| |
| SNOW_CONTACT_FORCE_N = 20.0 |
| |
| |
| |
| |
| |
| |
| |
| NJMAX = 128 |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| SLOPE_RAD = 0.0 |
| |
| |
| |
| MAP_OBS_STD_N = 70.0 |
| MAP_OBS_STD_M = 0.05 |
|
|
|
|
| def default_config() -> config_dict.ConfigDict: |
| """Playground's G1 config plus the snow terms. Stock values are untouched.""" |
| cfg = joystick.default_config() |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| cfg.reward_config.scales.snow_clearance = 0.8 |
| cfg.reward_config.scales.snow_stumble = -1.0 |
| cfg.reward_config.scales.snow_flat_efficiency = -0.5 |
| cfg.reward_config.scales.snow_overload = -0.4 |
| |
| |
| |
| |
| cfg.njmax = NJMAX |
| cfg.snow = config_dict.create( |
| enable_forces=True, |
| |
| |
| |
| estimator_channel=1.0, |
| |
| |
| map_channel=1.0, |
| support_channel=1.0, |
| false_safe_rate=-1.0, |
| terrain_bank_size=TERRAIN_BANK_SIZE, |
| terrain_seed=0, |
| |
| |
| |
| slope_rad=SLOPE_RAD, |
| |
| |
| depth_scale=1.0, |
| ) |
| return cfg |
|
|
|
|
| class SnowJoystick(joystick.Joystick): |
| """G1 joystick locomotion over variable-depth snow.""" |
|
|
| def __init__( |
| self, |
| task: str = "flat_terrain", |
| config: config_dict.ConfigDict | None = None, |
| config_overrides: dict[str, Any] | None = None, |
| ): |
| |
| |
| |
| |
| mjx_env.ensure_menagerie_exists() |
| super().__init__(task=task, config=config or default_config(), |
| config_overrides=config_overrides) |
|
|
| def _post_init(self) -> None: |
| super()._post_init() |
| slope = float(self._config.snow.slope_rad) |
| if slope != 0.0: |
| |
| g = float(np.linalg.norm(self._mj_model.opt.gravity)) |
| |
| |
| |
| |
| |
| tilted = np.array([-g * np.sin(slope), 0.0, -g * np.cos(slope)]) |
| self._mj_model.opt.gravity[:] = tilted |
| self._mjx_model = self._mjx_model.replace(opt=self._mjx_model.opt.replace( |
| gravity=jnp.asarray(tilted))) |
| self._terrain_cfg = TERRAIN_CFG |
| rate = self._config.snow.false_safe_rate |
| self._est_params = estimator.load_params( |
| false_safe_rate=None if rate < 0 else rate |
| ) |
| |
| self._feet_body_id = jnp.asarray(self._mj_model.site_bodyid[self._feet_site_id]) |
| self._terrain_bank: SnowField = batched_generate( |
| jax.random.split(jax.random.key(self._config.snow.terrain_seed), |
| self._config.snow.terrain_bank_size), |
| self._terrain_cfg, |
| float(self._config.snow.depth_scale), |
| ) |
|
|
| |
|
|
| def _field_at(self, index: jax.Array) -> SnowField: |
| return jax.tree_util.tree_map(lambda leaf: leaf[index], self._terrain_bank) |
|
|
| def _sample_feet(self, field: SnowField, feet_xyz: jax.Array): |
| """Snow state under each foot. Returns (depth, bearing, stiffness, damping, void).""" |
| return sample(field, self._terrain_cfg, feet_xyz[:, :2]) |
|
|
| |
|
|
| def reset(self, rng: jax.Array) -> mjx_env.State: |
| rng, terrain_rng, est_rng = jax.random.split(rng, 3) |
| state = super().reset(rng) |
|
|
| index = jax.random.randint(terrain_rng, (), 0, self._config.snow.terrain_bank_size) |
| est_state = estimator.init_state(est_rng, self._est_params) |
| feet_xyz = state.data.site_xpos[self._feet_site_id] |
| field = self._field_at(index) |
| depth, bearing, _, _, void = self._sample_feet(field, feet_xyz) |
|
|
| obs8, est_state = estimator.estimate( |
| est_rng, est_state, depth, bearing, void > 0.5, |
| jnp.ones((2,), dtype=bool), self._est_params, |
| ) |
| belief_map = belief.init() |
| state.info["snow_belief"] = belief_map |
| state.info["snow_map_readout"] = belief.readout(belief_map, feet_xyz[:, :2]) |
| state.info["snow_support_obs"] = support.observation( |
| support.evaluate(bearing * 0.0 + support.G1_WEIGHT_N, |
| jnp.zeros(2), jnp.zeros(2)) |
| ) |
| state.info["snow_overload"] = jnp.zeros(()) |
| state.info["snow_terrain_index"] = index |
| state.info["snow_obs"] = obs8 |
| state.info["snow_history"] = jnp.tile(obs8, HISTORY_STEPS) |
| state.info["snow_truth"] = jnp.concatenate([depth, bearing]) |
| state.info["snow_force"] = jnp.zeros((2, 3)) |
| state.info["snow_last_feet_pos"] = feet_xyz |
| state.info["snow_est_last_obs"] = est_state.last_obs |
| state.info["snow_est_false_safe"] = est_state.false_safe |
| state.info["snow_est_noise_scale"] = est_state.noise_scale |
|
|
| state = state.replace(obs=self._get_obs(state.data, state.info, |
| state.info["last_contact"])) |
| for key in ("snow_clearance", "snow_stumble", "snow_flat_efficiency", |
| "snow_overload"): |
| state.metrics[f"reward/{key}"] = jnp.zeros(()) |
| state.metrics["snow_depth_under_feet"] = jnp.mean(depth) |
| state.metrics["snow_contact_fraction"] = jnp.zeros(()) |
| return state |
|
|
| def step(self, state: mjx_env.State, action: jax.Array) -> mjx_env.State: |
| field = self._field_at(state.info["snow_terrain_index"]) |
| feet_xyz = state.data.site_xpos[self._feet_site_id] |
| |
| feet_vel = (feet_xyz - state.info["snow_last_feet_pos"]) / self.dt |
| depth, bearing, stiffness, damping, void = self._sample_feet(field, feet_xyz) |
| void_b = void > 0.5 |
|
|
| |
| |
| force = sinkage.foot_force_world_n( |
| feet_xyz[:, 2], feet_vel, jnp.zeros((2,)), depth, stiffness, damping, void_b |
| ) |
| force = force * self._config.snow.enable_forces |
|
|
| |
| |
| state.info["rng"], est_rng = jax.random.split(state.info["rng"]) |
| new_stance = self._bearing_load(force) & ~state.info["last_contact"].astype(bool) |
| est_state = estimator.EstimatorState( |
| last_obs=state.info["snow_est_last_obs"], |
| false_safe=state.info["snow_est_false_safe"], |
| noise_scale=state.info["snow_est_noise_scale"], |
| ) |
| obs8, est_state = estimator.estimate( |
| est_rng, est_state, depth, bearing, void_b, new_stance, self._est_params |
| ) |
|
|
| |
| est = obs8.reshape(estimator.N_FEET, estimator.N_CHANNELS) |
| support_est_n = est[:, 1] * estimator.SUPPORT_NORM_N |
| implied = estimator.implied_depth_m(est, self._est_params) |
| belief_map = state.info["snow_belief"] |
| for foot in range(estimator.N_FEET): |
| belief_map = belief.update( |
| belief_map, feet_xyz[foot, :2], support_est_n[foot], |
| jnp.asarray(MAP_OBS_STD_N), implied[foot], jnp.asarray(MAP_OBS_STD_M), |
| ) |
| state.info["snow_belief"] = belief_map |
| state.info["snow_map_readout"] = belief.readout(belief_map, feet_xyz[:, :2]) |
|
|
| sup = support.evaluate(support_est_n, jnp.full((2,), MAP_OBS_STD_N), force[:, 2]) |
| state.info["snow_support_obs"] = support.observation(sup) |
| state.info["snow_overload"] = support.overload_cost( |
| sup, self._bearing_load(force) |
| ) |
|
|
| state.info["snow_obs"] = obs8 |
| state.info["snow_history"] = jnp.concatenate( |
| [obs8, state.info["snow_history"][: estimator.OBS_SIZE * (HISTORY_STEPS - 1)]] |
| ) |
| state.info["snow_truth"] = jnp.concatenate([depth, bearing]) |
| state.info["snow_force"] = force |
| state.info["snow_last_feet_pos"] = feet_xyz |
| state.info["snow_est_last_obs"] = est_state.last_obs |
| state.info["snow_est_false_safe"] = est_state.false_safe |
| state.info["snow_est_noise_scale"] = est_state.noise_scale |
|
|
| state = self._stepped_with_snow_contact(state, action, field) |
| state.metrics["snow_depth_under_feet"] = jnp.mean(depth) |
| return state |
|
|
| def _snow_force_at(self, data, prev_feet_xyz, field, dt: float): |
| """Snow force on both feet given the current physics state.""" |
| feet_xyz = data.site_xpos[self._feet_site_id] |
| feet_vel = (feet_xyz - prev_feet_xyz) / dt |
| depth, _, stiffness, damping, void = self._sample_feet(field, feet_xyz) |
| force = sinkage.foot_force_world_n( |
| feet_xyz[:, 2], feet_vel, jnp.zeros((2,)), depth, stiffness, damping, void > 0.5 |
| ) |
| return force * self._config.snow.enable_forces, feet_xyz |
|
|
| def _physics_with_snow(self, data, motor_targets: jax.Array, field): |
| """Substep the physics, recomputing the snow force at EVERY substep. |
| |
| Playground's `mjx_env.step` holds `xfrc_applied` constant across all 10 substeps. |
| For a stiff spring applied as an external force that zero-order hold is an explicit |
| integrator, and it is unstable above k ~ m/ctrl_dt^2 ~ 5000 N/m with a ~2 kg effective |
| foot mass. The terrain generates stiffness up to 45000 N/m, so most of the range |
| diverged: measured growth 4.0x at k=12000 and 9.1x at k=45000, which showed up in |
| rollouts as the force pinned at its 2000 N clamp and swing feet launched to 0.67 m. |
| |
| Recomputing inside the substep loop moves the limit to m/sim_dt^2 ~ 500000 N/m, |
| comfortably above the terrain's maximum. Measured growth is 1.0x at every stiffness. |
| |
| The estimator, belief map and reward stay at the 50 Hz control rate -- those are |
| sensor-rate quantities and nothing about them is unstable. |
| """ |
| def substep(carry, _): |
| d, prev = carry |
| force, feet_xyz = self._snow_force_at(d, prev, field, self.sim_dt) |
| xfrc = jnp.zeros_like(d.xfrc_applied).at[self._feet_body_id, :3].set(force) |
| d = d.replace(ctrl=motor_targets, xfrc_applied=xfrc) |
| d = mjx.step(self.mjx_model, d) |
| return (d, feet_xyz), force |
|
|
| feet0 = data.site_xpos[self._feet_site_id] |
| (data, _), forces = jax.lax.scan( |
| substep, (data, feet0), None, length=self.n_substeps |
| ) |
| |
| return data, forces[-1] |
|
|
| def _bearing_load(self, snow_force: jax.Array) -> jax.Array: |
| """Feet whose vertical snow force is carrying meaningful load.""" |
| return snow_force[:, 2] > SNOW_CONTACT_FORCE_N |
|
|
| def _stepped_with_snow_contact(self, state: mjx_env.State, action: jax.Array, |
| field) -> mjx_env.State: |
| """Playground's Joystick.step with ONE change: what counts as foot contact. |
| |
| Playground reads a floor-contact sensor at z=0. Once snow is a force rather than |
| geometry that sensor stops meaning "the foot is standing on something": measured on |
| deep terrain it fired on 2 of 35 steps, because the snow held the foot at z~0.156 m |
| and it never reached the plane. Everything keyed to it broke -- `first_contact` never |
| fired so the clearance reward was dead exactly where the signal lives, `swing_peak` |
| never reset, and `feet_air_time` grew without bound. |
| |
| Contact here means BEARING LOAD: the floor sensor OR snow carrying real weight. |
| |
| This mirrors the upstream step body; re-check it when bumping mujoco_playground. |
| """ |
| state.info["rng"], push1_rng, push2_rng = jax.random.split(state.info["rng"], 3) |
| push_theta = jax.random.uniform(push1_rng, maxval=2 * jnp.pi) |
| push_magnitude = jax.random.uniform( |
| push2_rng, |
| minval=self._config.push_config.magnitude_range[0], |
| maxval=self._config.push_config.magnitude_range[1], |
| ) |
| push = jnp.array([jnp.cos(push_theta), jnp.sin(push_theta)]) |
| push *= ( |
| jnp.mod(state.info["push_step"] + 1, state.info["push_interval_steps"]) == 0 |
| ) |
| push *= self._config.push_config.enable |
| qvel = state.data.qvel |
| qvel = qvel.at[:2].set(push * push_magnitude + qvel[:2]) |
| state = state.replace(data=state.data.replace(qvel=qvel)) |
|
|
| motor_targets = self._default_pose + action * self._config.action_scale |
| data, applied_force = self._physics_with_snow(state.data, motor_targets, field) |
| state.info["motor_targets"] = motor_targets |
| state.info["snow_force"] = applied_force |
|
|
| floor_contact = jnp.array([ |
| data.sensordata[self._mj_model.sensor_adr[sensorid]] > 0 |
| for sensorid in self._feet_floor_found_sensor |
| ]) |
| |
| contact = floor_contact | self._bearing_load(state.info["snow_force"]) |
|
|
| contact_filt = contact | state.info["last_contact"] |
| first_contact = (state.info["feet_air_time"] > 0.0) * contact_filt |
| state.info["feet_air_time"] += self.dt |
| p_fz = data.site_xpos[self._feet_site_id][..., -1] |
| state.info["swing_peak"] = jnp.maximum(state.info["swing_peak"], p_fz) |
|
|
| obs = self._get_obs(data, state.info, contact) |
| done = self._get_termination(data) |
|
|
| rewards = self._get_reward( |
| data, action, state.info, state.metrics, done, first_contact, contact |
| ) |
| rewards = {k: v * self._config.reward_config.scales[k] for k, v in rewards.items()} |
| reward = sum(rewards.values()) * self.dt |
|
|
| state.info["push"] = push |
| state.info["step"] += 1 |
| state.info["push_step"] += 1 |
| phase_tp1 = state.info["phase"] + state.info["phase_dt"] |
| state.info["phase"] = jnp.fmod(phase_tp1 + jnp.pi, 2 * jnp.pi) - jnp.pi |
| state.info["last_last_act"] = state.info["last_act"] |
| state.info["last_act"] = action |
| state.info["rng"], cmd_rng = jax.random.split(state.info["rng"]) |
| state.info["command"] = jnp.where( |
| state.info["step"] > 500, self.sample_command(cmd_rng), state.info["command"] |
| ) |
| state.info["step"] = jnp.where(done | (state.info["step"] > 500), 0, |
| state.info["step"]) |
| state.info["feet_air_time"] *= ~contact |
| state.info["last_contact"] = contact |
| state.info["swing_peak"] *= ~contact |
| for k, v in rewards.items(): |
| state.metrics[f"reward/{k}"] = v |
| state.metrics["swing_peak"] = jnp.mean(state.info["swing_peak"]) |
| state.metrics["snow_contact_fraction"] = jnp.mean(contact.astype(float)) |
|
|
| done = done.astype(reward.dtype) |
| return state.replace(data=data, obs=obs, reward=reward, done=done) |
|
|
| |
|
|
| def _snow_block(self, info: dict[str, Any]) -> jax.Array: |
| |
| |
| history = info.get("snow_history") |
| if history is None: |
| return jnp.zeros(estimator.OBS_SIZE * HISTORY_STEPS) |
| return history * self._config.snow.estimator_channel |
|
|
| def _assurance_block(self, info: dict[str, Any]) -> jax.Array: |
| """everest's half: spatial memory plus bilateral support reserve.""" |
| readout = info.get("snow_map_readout") |
| if readout is None: |
| readout = jnp.zeros(belief.READOUT_SIZE) |
| sup = info.get("snow_support_obs") |
| if sup is None: |
| sup = jnp.zeros(5) |
| return jnp.hstack([ |
| readout * self._config.snow.map_channel, |
| sup * self._config.snow.support_channel, |
| ]) |
|
|
| def _get_obs(self, data, info: dict[str, Any], contact: jax.Array): |
| obs = super()._get_obs(data, info, contact) |
| block = jnp.hstack([self._snow_block(info), self._assurance_block(info)]) |
| return { |
| "state": jnp.hstack([obs["state"], block]), |
| |
| "privileged_state": jnp.hstack([ |
| obs["privileged_state"], block, |
| info.get("snow_truth", jnp.zeros(2 * estimator.N_FEET)), |
| ]), |
| } |
|
|
| |
|
|
| def _get_reward(self, data, action, info, metrics, done, first_contact, contact): |
| rewards = super()._get_reward(data, action, info, metrics, done, |
| first_contact, contact) |
| obs8 = info["snow_obs"].reshape(estimator.N_FEET, estimator.N_CHANNELS) |
| implied_depth = estimator.implied_depth_m(obs8, self._est_params) |
| confidence = obs8[:, 2] |
|
|
| rewards["snow_clearance"] = self._reward_snow_clearance( |
| info["swing_peak"], implied_depth, confidence, first_contact |
| ) |
| rewards["snow_stumble"] = self._cost_snow_stumble(info["snow_force"], contact) |
| rewards["snow_flat_efficiency"] = self._cost_snow_flat_efficiency( |
| info["swing_peak"], implied_depth, confidence, first_contact |
| ) |
| |
| |
| rewards["snow_overload"] = info["snow_overload"] |
| return rewards |
|
|
| def _reward_snow_clearance(self, swing_peak, implied_depth, confidence, first_contact): |
| """Score the swing foot's peak height against the depth the ESTIMATE implies. |
| |
| Targets a value rather than rewarding 'higher is better' -- unbounded lifting is its |
| own failure mode. Scored once per step, when the swing ends. |
| """ |
| target = implied_depth + CLEARANCE_MARGIN_M |
| score = jnp.exp(-((swing_peak - target) ** 2) / CLEARANCE_TOLERANCE) |
| return jnp.sum(score * confidence * first_contact) |
|
|
| def _cost_snow_stumble(self, snow_force, contact): |
| """Ploughing drag on a foot that should be swinging freely. |
| |
| A physics consequence, so it may read the real force rather than the estimate. |
| Graded rather than binary: a step function gives the optimiser no gradient about |
| *how much* it is ploughing, only whether it crossed a line. |
| """ |
| horizontal = jnp.linalg.norm(snow_force[:, :2], axis=-1) |
| excess = jnp.maximum(horizontal - STUMBLE_PLOUGH_N, 0.0) / STUMBLE_PLOUGH_N |
| return jnp.sum(excess * ~contact.astype(bool)) |
|
|
| def _cost_snow_flat_efficiency(self, swing_peak, implied_depth, confidence, first_contact): |
| """Penalise lifting high when the estimate says the ground is firm. |
| |
| Without this the policy prances everywhere, which technically satisfies the clearance |
| term and is obviously wrong. |
| """ |
| on_flat = implied_depth < FLAT_DEPTH_THRESHOLD_M |
| excess = jnp.maximum(swing_peak - FLAT_NOMINAL_CLEARANCE_M, 0.0) |
| return jnp.sum(excess * on_flat * confidence * first_contact) |
|
|
|
|
| def make_snow_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick: |
| return SnowJoystick(config_overrides=config_overrides) |
|
|
|
|
| def make_no_map_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick: |
| """Spatial memory blanked, everything else intact. |
| |
| This is the run everest could never do. Its own benchmark conflated the map with the |
| bilateral gate, and the decomposition showed the gate did all the work -- so the map's |
| contribution has never actually been measured. Against the full env, this isolates it. |
| """ |
| overrides = dict(config_overrides or {}) |
| overrides["snow.map_channel"] = 0.0 |
| return SnowJoystick(config_overrides=overrides) |
|
|
|
|
| def make_no_support_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick: |
| """Bilateral reserve blanked, everything else intact.""" |
| overrides = dict(config_overrides or {}) |
| overrides["snow.support_channel"] = 0.0 |
| return SnowJoystick(config_overrides=overrides) |
|
|
|
|
| def make_ablation_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick: |
| """Identical environment with the estimator block zeroed. |
| |
| This is the comparison that isolates the sensor channel. Baseline-vs-trained differs in |
| three ways -- channel, rewards, AND snow training experience -- so only this run can |
| attribute a gap to the sensor. |
| """ |
| overrides = dict(config_overrides or {}) |
| overrides["snow.estimator_channel"] = 0.0 |
| overrides["snow.map_channel"] = 0.0 |
| overrides["snow.support_channel"] = 0.0 |
| return SnowJoystick(config_overrides=overrides) |
|
|