File size: 9,095 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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""Step 6 evaluation: roll out policies on a fixed strip and produce the comparison plots.

Written before training so the analysis exists when checkpoints land. Works with any policy
callable, including a zero/random policy, so the plumbing is testable with no GPU.

The four measurements from the brief:

* **swing clearance vs. implied depth** -- the headline. Baseline flat (no depth signal to
  condition on), trained rising.
* **falls per N runs** -- the blunt outcome.
* **velocity held through the deep section** -- the trained policy should keep moving where
  the baseline bogs down.
* **cost of transport** -- proof it is not simply high-stepping everywhere.

The honest caveat that belongs on any plot produced here: a baseline-vs-trained gap is
attributable to the sensor channel ONLY via the ablation env (`make_ablation_env`), because
the trained policy also saw snow. See docs/PIPELINE.md section 8.
"""
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field

import jax
import jax.numpy as jnp
import numpy as np

from snow_rl import estimator

Policy = Callable[[dict, jax.Array], jax.Array]

G1_MASS_KG = 35.0
GRAVITY = 9.81


@dataclass
class RolloutLog:
    """Per-step traces from one episode."""
    swing_peak_m: list = field(default_factory=list)       # (T, 2)
    landed: list = field(default_factory=list)             # (T, 2) foot touched down
    implied_depth_m: list = field(default_factory=list)    # (T, 2)
    true_depth_m: list = field(default_factory=list)       # (T, 2)
    velocity_mps: list = field(default_factory=list)       # (T,)
    commanded_mps: list = field(default_factory=list)      # (T,)
    x_position_m: list = field(default_factory=list)       # (T,)
    torque_power_w: list = field(default_factory=list)     # (T,)
    fell: bool = False

    def stack(self) -> dict[str, np.ndarray]:
        return {
            "swing_peak_m": np.asarray(self.swing_peak_m),
            "landed": np.asarray(self.landed),
            "implied_depth_m": np.asarray(self.implied_depth_m),
            "true_depth_m": np.asarray(self.true_depth_m),
            "velocity_mps": np.asarray(self.velocity_mps),
            "commanded_mps": np.asarray(self.commanded_mps),
            "x_position_m": np.asarray(self.x_position_m),
            "torque_power_w": np.asarray(self.torque_power_w),
            "fell": self.fell,
        }


def zero_policy(obs: dict, rng: jax.Array) -> jax.Array:
    """Placeholder used to exercise the pipeline before a checkpoint exists."""
    return None  # replaced by caller-supplied action size


def rollout(env, policy: Policy, rng: jax.Array, steps: int = 400) -> dict[str, np.ndarray]:
    """One episode. `policy(obs, rng) -> action`."""
    step_fn = jax.jit(env.step)
    state = jax.jit(env.reset)(rng)
    log = RolloutLog()
    prev_contact = np.zeros(2, dtype=bool)

    for _ in range(steps):
        rng, act_rng = jax.random.split(rng)
        action = policy(state.obs, act_rng)
        if action is None:
            action = jnp.zeros(env.action_size)
        state = step_fn(state, action)

        obs8 = state.info["snow_obs"].reshape(estimator.N_FEET, estimator.N_CHANNELS)
        implied = estimator.implied_depth_m(obs8, env._est_params)
        truth = state.info["snow_truth"]

        log.swing_peak_m.append(np.asarray(state.info["swing_peak"]))
        # swing_peak resets on contact, so it is only MEANINGFUL at a landing. Recording the
        # landing mask lets clearance_vs_depth use those samples instead of every step, where
        # the value is mid-swing or freshly zeroed.
        log.landed.append(np.asarray(state.info["last_contact"]) & ~prev_contact)
        prev_contact = np.asarray(state.info["last_contact"])
        log.implied_depth_m.append(np.asarray(implied))
        log.true_depth_m.append(np.asarray(truth[: estimator.N_FEET]))
        log.velocity_mps.append(float(state.data.qvel[0]))
        log.commanded_mps.append(float(state.info["command"][0]))
        log.x_position_m.append(float(state.data.qpos[0]))
        log.torque_power_w.append(
            float(jnp.sum(jnp.abs(state.data.actuator_force * state.data.qvel[6:])))
        )
        if float(state.done) > 0.5:
            log.fell = True
            break
    return log.stack()


def cost_of_transport(trace: dict[str, np.ndarray]) -> float:
    """Dimensionless: energy per unit weight per unit distance."""
    distance = abs(trace["x_position_m"][-1] - trace["x_position_m"][0])
    if distance < 1e-3:
        return float("nan")
    energy = float(np.sum(trace["torque_power_w"])) * 0.02   # ctrl_dt
    return energy / (G1_MASS_KG * GRAVITY * distance)


def summarise(traces: list[dict[str, np.ndarray]], deep_threshold_m: float = 0.10) -> dict:
    """Aggregate one policy's runs into the four headline numbers."""
    falls = sum(int(t["fell"]) for t in traces)
    deep_speeds, cots = [], []
    for t in traces:
        deep = t["true_depth_m"].mean(axis=1) > deep_threshold_m
        if deep.any():
            deep_speeds.append(float(np.mean(t["velocity_mps"][deep])))
        cot = cost_of_transport(t)
        if np.isfinite(cot):
            cots.append(cot)
    return {
        "runs": len(traces),
        "falls": falls,
        "fall_rate": falls / max(len(traces), 1),
        "deep_section_velocity_mps": float(np.mean(deep_speeds)) if deep_speeds else float("nan"),
        "cost_of_transport": float(np.mean(cots)) if cots else float("nan"),
    }


def clearance_vs_depth(traces: list[dict[str, np.ndarray]], bins: int = 10):
    """The headline relationship: swing clearance as a function of implied depth.

    Returns (bin_centres, mean_clearance, std_clearance, slope). A flat line means the policy
    is not conditioning on the estimate.
    """
    depth = np.concatenate([t["implied_depth_m"].ravel() for t in traces])
    peak = np.concatenate([t["swing_peak_m"].ravel() for t in traces])
    if all("landed" in t and t["landed"].size for t in traces):
        mask = np.concatenate([t["landed"].ravel() for t in traces]).astype(bool)
    else:
        mask = peak > 0
    ok = np.isfinite(depth) & np.isfinite(peak) & mask
    depth, peak = depth[ok], peak[ok]

    # Say WHY there is no curve rather than returning empty arrays. This is the headline
    # plot; a silent blank here reads as "the policy does not condition on the estimate",
    # which is a very different claim from "the rollout produced too few landings".
    if depth.size < 2 * bins:
        raise ValueError(
            f"only {depth.size} landing samples across {len(traces)} rollouts; need "
            f"{2 * bins}. Run longer episodes or more of them."
        )
    if depth.std() < 1e-9:
        raise ValueError(
            "the implied-depth estimate does not vary across these rollouts, so no "
            "relationship can be measured -- check the terrain regime mix"
        )

    bins = min(bins, max(2, depth.size // 5))
    edges = np.linspace(depth.min(), depth.max(), bins + 1)
    idx = np.clip(np.digitize(depth, edges) - 1, 0, bins - 1)
    centres, means, stds = [], [], []
    for b in range(bins):
        m = idx == b
        if m.sum() >= 2:
            centres.append(0.5 * (edges[b] + edges[b + 1]))
            means.append(float(peak[m].mean()))
            stds.append(float(peak[m].std()))
    slope = float(np.cov(depth, peak)[0, 1] / np.var(depth))
    return np.asarray(centres), np.asarray(means), np.asarray(stds), slope


def sensor_dependence_check(env, policy: Policy, rng: jax.Array,
                            fabricated_depth_m: float = 0.18, steps: int = 200) -> dict:
    """Feed a fabricated high depth estimate while the ground is genuinely firm.

    If the policy steps high over nothing, the sensor channel really drives the gait. If it
    walks normally, the policy learned to feel terrain through its own joints -- interesting,
    but not the claimed result. Report either outcome.

    Sweep WITHIN the range seen in training: fabricating a value the policy never saw is out
    of distribution and a null result there would be ambiguous.
    """
    normal = rollout(env, policy, rng, steps=steps)
    faked = rollout(env, _spoofing_policy(policy, fabricated_depth_m, env), rng, steps=steps)
    return {
        "fabricated_depth_m": fabricated_depth_m,
        "clearance_normal_m": float(np.nanmean(normal["swing_peak_m"])),
        "clearance_fabricated_m": float(np.nanmean(faked["swing_peak_m"])),
    }


def _spoofing_policy(policy: Policy, depth_m: float, env) -> Policy:
    """Overwrite the depth channel in the observation before the policy sees it."""
    block = estimator.OBS_SIZE * 3
    normalised = depth_m / estimator.DEPTH_NORM_M

    def spoofed(obs: dict, rng: jax.Array) -> jax.Array:
        state = obs["state"]
        start = state.shape[-1] - block
        idx = jnp.arange(start, state.shape[-1], estimator.N_CHANNELS)
        state = state.at[idx].set(normalised)
        return policy({**obs, "state": state}, rng)

    return spoofed