System Identification   Sim-to-Real   2026

Measured the robot.
Then trained on it.

I measured the leg joints on a real Unitree G1, corrected the simulator to match, and trained a walking policy on the corrected model. The largest error turned out to be a parameter the standard identification tools don't fit at all. The robot walks at 0.6 m/s and I drive it from the keyboard.

1.46×
Better on data it never saw
0.6 m/s
Walking on hardware
76–95%
Torque actually delivered
0.29°
Repeatability floor
Walking, uncut
Walking at 0.6 m/s, driven from the keyboard. The hoist strap is slack, there as a catch.

Commands go over the Unitree SDK at 50 Hz. The policy sees only the IMU and the joint encoders, and it was trained entirely in a simulator I had corrected against measurements of this exact robot.

How it works

The simulator gets the robot's shape and mass from CAD, so those are already right. What it doesn't get is what happens inside each joint: the motor's own spinning inertia multiplied up by the gearbox, a viscous drag that grows with speed, and dry friction that resists the same at any speed.

None of it can be measured directly through a sealed gearbox. So I drive the joints through a motion I designed, record what they actually did, replay the same commands in the simulator, and adjust the parameters until the simulated joints move like the recording. The parameters never get measured directly. They come out as whatever values make the model match the data.

Result

I start the simulator from the robot's initial state, feed it the same 30 seconds of commands, never correct it along the way, and measure how far it drifts. On data the fit never saw:

Nominal model
0.0365 rad — 2.09° of drift
Identified model
0.0251 rad — 1.44°, a 1.46× improvement
Second held-out set, at double the servo gains
0.0246 rad. So it holds at a different operating point, not just on more of the same data.
Run-to-run repeatability
0.0051 rad. I ran the identical command twice. No model can do better than this.

The actuator gain

These tools fit rotor inertia, damping and friction, and treat the actuator gain as a known constant. It isn't one. I checked it by regressing the robot's own reported torque against the commanded Kp (q_des − q) − Kd q̇. That torque signal is the only thing in the dataset that doesn't come from the position trajectory the fit was already minimising, so it's an independent check rather than the same number twice.

The joints deliver 76–95% of the torque the model assumes. Correcting that moved held-out error more than rotor inertia, damping and friction combined: a 30× cost reduction on a single-joint fit, against 6% from all three dynamics parameters together.

The cause is in how the gains were picked. Kp = I ω² was applied with I as the rotor inertia, but the inertia the joint actually has to accelerate is 2–84× bigger. So a servo meant to be critically damped at 7 Hz is really 0.77 Hz at ζ = 0.22, badly underdamped. That is the 1 Hz ringing you can see in the before and after plots. The same formula is sitting in the upstream simulator's asset config.

Which parameters are real

A fitted number is only a measurement if the data was capable of determining it. I used three checks, none of which rely on the optimiser's own cost going down:

Left/right symmetry
Both legs use identical motors, so mirrored joints have to come out the same. Damping agrees within 14%, so that one is real. Rotor inertia disagrees by 6–60×, so that one is noise. Two fits of the same physical quantity landing that far apart settles it.
Refitting under different settings
Coulomb friction came out 0.001, 0.115, 0.18, 1.16 and 0.53 N·m across five fits. That's three orders of magnitude, and predictive accuracy changed by under 3% between them, so the data can't pin it down at all. I measured it a different way instead: hang the robot, cut all torque, and record how much gravity torque each joint holds before it starts sliding. That gives 0.16–0.85 N·m against the model's flat 0.3 guess.
Held-out scoring
Fitting link masses improved training error and made held-out error worse than not fitting them at all. The reason is exact collinearity: rotor inertia and link inertia produce the same column in the Jacobian, so fitting both makes the system singular.

This is what sets the domain randomisation for the RL, instead of me guessing the ranges: ±5% on the parameter I could pin down, 0.2–2.0 N·m on the one I couldn't. Identification puts the centre of the distribution in the right place, and randomisation covers the spread that's left. Widening the randomisation around a wrong centre doesn't fix it, because a systematic 18% torque error is wrong in the same direction on every sample. The real robot stays outside the training distribution however wide you make it.

What I got wrong

Four things I believed and then had to drop. Each one was killed by a measurement, not by me changing my mind about it.

Unmodelled actuator delay
I fitted a delay parameter. It landed inside its range and the cost got worse.
A mechanically jammed ankle
I had the same suspicious number show up in two experiments. The joint was just under-driven: 0.101 → 0.652 rad once I gave it more amplitude.
High-frequency excitation for inertia
At constant amplitude, acceleration goes as f². True, and useless at four times the servo's actual bandwidth.
Overestimated link inertia
Rejected by held-out data, and by parameters pinning themselves at their bounds.

On the robot

PPO in MuJoCo on the identified model, 32k iterations, then deployed over the Unitree SDK at 50 Hz. The policy's whole input is the IMU and the joint encoders. No state estimator, no contact sensors, no terrain scan, because the hardware doesn't have any of them. It stands for a minute with torques at 3–31% of limits, and walks at 0.6 m/s.

It also runs with 60–80 ms of measured command latency when it was only trained for 0–20 ms. That's three to four times the mismatch, and it handles it better than I expected it to.

Limits

This is flat ground, with a hoist strap left slack as a catch. Every parameter was measured with the base welded and the legs hanging in the air, so all of it is joint and actuator dynamics. It says nothing about contact, which is the biggest untested part of the model. Below roughly 0.4 m/s the policy just stands there, because the tracking reward makes standing worth 85% of maximum at a 0.2 m/s command. That's the reward function doing what I told it to, not a failure.

Stack

MuJoCo · mjlab · PPO via rsl_rl · Unitree SDK2 over CycloneDDS · ONNX Runtime · simulation-in-the-loop nonlinear least squares