First, make it walk
where the runner and the system ID came fromThe lab had two goals: walk, and get up after falling. Walking came first, and built everything the recovery work later depended on. I started by measuring the machine.
| Measured | Where it was used | |
|---|---|---|
| Mass | 964.8 g, on the bench, as it runs. | The vendor's model weighs 748.8 g. The difference went in as a back weight at the measured location. |
| Command onset | 61–70 ms from command to the start of motion (step response). | Into training as the action delay, so the policy acts early. |
| State stream | 50 Hz, no jitter, over the 2 Mbaud link. | The rate the policy runs at, and that every log here was recorded at. |
| Servo top speed | 644°/s, free run. | The model's speed ceiling, so training cannot buy a motion the hardware cannot perform. |
| Gain and lag, 1–3 Hz | 0.60 gain, 88–104 ms phase lag (sine sweep). | The simulator's own figures are 0.81 and 163 ms. This pair is also what rules out standing on two legs, below. |
| Gyro bias, at rest | −7.5, +7.8, +54.5 °/s. Large, but stable run to run. | Subtracted at startup. Left in, the attitude estimate turns 54° a second on its own. |
The walking policy is the vendor's task, trained unchanged
I had hand-built and hand-tuned twenty versions of my own first. On 22 July the vendor published a task written for this exact machine; trained untouched for 1,500 iterations — about eighteen minutes — it walked better than anything I had.
The runner it shipped with does not read the policy it serves
| The runner hard-codes | The policy's metadata says | |
|---|---|---|
| Default stance · calves | 0° on all four calf joints. | −15.99°. The constant is applied twice per step — in the observation as q − default, and again in the action as default + scale·a. |
| Default stance · arm | 70° on the arm's second segment. | +89.95°. Five of fifteen joints disagree in total. |
| Joint ordering | A fixed servo-ID list. | Read from joint_names. Two generations of policy for this
robot order the rear legs differently; a fixed list silently swaps them. |
| Gyro bias | Raw firmware axes, no bias removed — a +54.5°/s rotation the robot is not doing, arriving every tick. | Two seconds of stationary calibration at startup, axes remapped to the body frame. |
| Action clamp | None. Commands could leave the range the machine can reach. | Joint limit ∩ actuator range, as in the simulator, carried in the exported metadata. |
| Action smoothing | None, and the RL firmware drops the factory 50 ms interpolator, so targets hit the servos as bare 20 ms steps. | 40% new target, 60% previous, on walking only — righting the machine needs the whole step. |
The robot could not tell it was upside down
Why it took so long to find
The recovery policy kept doing almost nothing, which looks like a bad policy. It was not. Its observation is the gravity vector, and the vector it was handed said you are already standing.
The cause is upstream of the policy and upstream of me. Running RL on this machine means flashing a preliminary firmware, v0.0.1, shipped with the trial package. The factory build reports attitude correctly through the full sphere; this one folds at 90° — and below 90° it looks perfect, so nothing in the walking work had ever touched it.
The fix
The raw gyro channels are unaffected, so I stopped trusting the firmware's attitude and integrated my own quaternion in the runner: axis remap, a two-second stationary calibration at startup, and a 2%-per-tick pull back toward vertical whenever the robot is standing.
Checked by hand across seven held poses, fully inverted included: 0–3° error.
The simulated robot was 216 g too light
The vendor's model weighed 748.8 g; the machine on the bench weighed 965 g. A policy trained on the light model had been promised a torque-to-weight ratio the real robot only had 77.6% of — so on hardware it saturated its actuators through the whole motion and never produced the trajectory it had learned.
The missing mass went into the model as a back weight at the measured location, and I retrained. Same task, corrected physics — never a gain adjusted to hide a modelling error.
How the recovery policy got there
every version, and the parameter that changed in itThe vendor's recovery task produces a policy that gets up, and it is slow. Thirteen rounds of one parameter at a time took the median from 3.62 s to 1.16 s in simulation, twenty attempts scored after every round, all on the mass-corrected model.
| Changed | Why | Measured | What happened | |
|---|---|---|---|---|
| G1 | Official cbf321a unchanged, on the corrected model. Seed 42, 4,096 environments, 1,500 iterations. | Baseline. | sim 13/20 · median 3.62 s · p90 timeout robot n=6 · median 5.65 s · 2 reached a stable stand |
Five stall in the roll from back to side; two overshoot and rock back. |
| G2 | recovery_progress 10.0 → 20.0 | The only term pointing at turning over — everything else is a penalty, so this is where the ceiling is. | sim 15/20 · median 3.18 s · p90 7.4 s | Reward hacking: it snaps its head up without planting a foot. |
| G3 | pose.gravity_z_threshold −0.75 → −0.55 | The stance reward only opened below 41° of tilt, leaving 40–90° with no gradient. The gate moves to 57°. | sim 17/20 · median 2.82 s · p90 6.3 s | ⭐ Cured G2's reward hacking. |
| G4 | joint_vel_l2 −3e-2 → −1.5e-2 | Turning over runs on leg momentum, and this penalty caps exactly that peak. | sim 18/20 · median 2.44 s · p90 5.0 s | Enough swing — but 40% more high-frequency chatter on landing. |
| G5 | action_rate_l2 −0.2 → −0.35 | Velocity penalty caps amplitude, rate penalty caps frequency. Loosen one, tighten the other. | sim 18/20 · median 2.29 s · p90 4.3 s | Chatter back down, speed kept. |
| G6 | Birth-pose curriculum added: roll and pitch ±60° → ±180°, widened over the first 400 iterations. | The official config calls curriculum.clear(): fully random from iteration 0, so the hardest supine cases drown in easy ones. | sim 20/20 · median 1.86 s · p90 3.0 s robot n=8 · median 2.90 s · 5 of 8 stable |
⭐ Largest single gain in the whole log. |
| G7 | episode_length_s 8.0 → 5.0 | In eight seconds it can grind its way up and still score — time pressure was never in the objective. | sim 20/20 · median 1.63 s · p90 2.6 s | — |
| G8 | entropy_coef 0.01 → 0.005, init_std 2.0 → 1.5 | Wanted less exploration noise and a crisper motion. | sim 17/20 · median 2.05 s · p90 4.8 s | ❌ 26% slower. Rolled back: it converges early on a conservative solution, action amplitude −22%. |
| G9 | num_steps_per_env 24 → 48 | A 5 s episode is 250 steps; a 24-step rollout sees a tenth of it, so the value estimate is biased. | sim 20/20 · median 1.47 s · p90 2.3 s | p90 tightens visibly. |
| G10 | gamma 0.993 → 0.997 | 0.993 is a horizon of about 143 steps, shorter than one 250-step episode — it cannot see the end of its own motion. | sim 20/20 · median 1.39 s · p90 2.14 s robot n=10 · median 2.17 s · 7 of 10 stable |
— |
| G11 | pose.std_standing: arm_calf 1.0 → 0.5, arm_thigh 0.25 → 0.20 | A std of 1.0 is barely a constraint. Replay showed the arm overswinging and pushing the body back down. | sim 20/20 · median 1.27 s · p90 1.94 s | Arm overshoot gone. |
| G12 | angular_momentum −0.2 → −0.35, threshold −0.4 → −0.2 | Fast now, so it began overshooting and rocking back. The gate moves from 66° to 78°: penalty on the finish, not the swing. | sim 20/20 · median 1.20 s · p90 1.80 s | Landing overshoot 12° → 4°. |
| G13 | Seed sweep 42 / 43 / 44, best median. | Seed lottery — the spread at one fixed configuration is not negligible: 1.16 / 1.24 / 1.37 s. | sim 20/20 · median 1.16 s · p90 1.74 s robot n=14 · median 1.92 s; ten of them 1.4–2.1 s (median 1.81 s), four stalled 5.36–10.46 s |
Delivered. This is the policy in every clip on this page. |
Everything else is the vendor's, untouched: the PPO body, the contact-friction randomization, the termination conditions, the 0.25 action scale — ten parameters moved in total. The robot rows are 50 Hz on-robot logs, segmented from peak tilt to the first sample below 25°.
Same policy, two floors it never trained on
Neither floor exists in the simulator — what the policy saw was a range of randomized friction and body-contact parameters, so this is domain randomization paying off, not a demo tuned per venue. These two clips sit near opposite ends of the range the same weights cover.
The bipedal variant does not work, and here is the proof
The obvious next ask was standing on two legs, and torque was never the constraint — the task uses 13% of the actuator ceiling. Time is. An inverted pendulum this size has a 136 ms characteristic time against the machine's own 88–104 ms phase lag at 1–3 Hz. You cannot stabilize a correction that arrives that late.
Rather than argue about it, I ran one decisive experiment: identical training, one variable changed. At 8000 iterations the real-latency run reached an uprightness of 0.56; the low-latency run reached 0.95.
That is a physics limit on this hardware, not a tuning problem, and it was reported as such. Knowing when to stop is part of the job.
What I actually did
The walking and recovery task configurations are the vendor’s open-source work (LuwuDynamics, mjlab / MuJoCo Warp), as is the deployment runner I started from. Mine is the mass correction and retrain, the system identification, the rewrite of that runner — observation contract, state machine, attitude estimator, safety layer — and the bipedal negative result.
Code not public — the working tree contains vendor-licensed material.
- Trained — walking and fall-recovery policies with RSL-RL across 4,096 parallel environments, exported to ONNX
- Measured — 50 Hz on-robot telemetry: attitude, joint angles and velocities, targets, actions. Every recovery timing and attitude trace here comes out of those logs; the mass, latency and servo-speed figures come from the bench characterization named beside them, and the uprightness figures from the simulator
- Deployed — the runner on the physical machine. Every clip on this page is a real run under it, not a simulator playback