Research intern · Student Innovation Center (SEI-LAB), Shanghai Jiao Tong University · Jul–Aug 2026

Quadruped Fall Recovery

Two policies for an XGO mini2s quadruped — walking, and getting up after a fall — retrained in simulation and put on the physical machine. Training was the easy half. The hard half came from the vendor stack lagging its own hardware: finding every place where the real machine and its firmware disagreed with the official model — a chassis 216 g heavier than the one in simulation, and an attitude estimator that could not represent lying on its back.

Supine → on its feet
1.4–2.1 s10 of 14 logged recoveries · the other 4 stalled at 5.4–10.5 s

Task 1

First, make it walk

where the runner and the system ID came from

The 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.

Walking a line across the tiles.
Measured on the machine, before any tuning
 MeasuredWhere 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

Deployment runner · hard-coded constants against the policy's own exported metadata
 The runner hard-codesThe policy's metadata says
Default stance · calves 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.

Fault 1

The robot could not tell it was upside down

Gravity-z against time through one flip. The gyro-quaternion estimate rises to +0.9, settles to about +0.7, and stays clearly positive for the 1.4 seconds the robot lies on its back; the firmware estimate touches zero at 90 degrees and then falls back to −0.85, reporting standing.
One flip, from the on-robot log. The firmware’s gravity vector never goes positive — it folds at 90° and mirrors back, so for the 1.4 s the robot is genuinely supine it reports an attitude close to standing.

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.


Fault 2

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.

Body tilt against time for one recovery: it peaks near 160 degrees, holds through a one-second compliant phase, then falls below 20 degrees during a 1.22-second righting motion.
One recovery from the same log; the compliant hold is deliberate, reproducing the one-second settle the policy saw in training. This is one of the ten clean runs. Across all 14 logged recoveries, ten reached standing in 1.4–2.1 s (the righting motion itself 1.1–1.7 s); the other four stalled part-way up and needed 5.4–10.5 s.

Version log

How the recovery policy got there

every version, and the parameter that changed in it

The 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.

Recovery policy · tuning the vendor's task on the 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°.


Result

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.

Polished tile · low friction The feet slip, so the righting motion has to work against a floor that gives nothing back.
Artificial turf · high friction, compliant The surface grips and deforms under the body. Same network, no retraining, no parameter change.

Negative result

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.

Top: a bar showing the 136 ms pendulum time constant with the 88 to 104 ms command lag filling most of it. Bottom: uprightness of 0.56 at real latency against 0.95 at low latency.
The lag eats most of the window the controller has to act in — and the experiment agrees with the arithmetic.

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