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.
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.
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.
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:
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.
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:
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.
Four things I believed and then had to drop. Each one was killed by a measurement, not by me changing my mind about it.
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.
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.
MuJoCo · mjlab · PPO via rsl_rl · Unitree SDK2 over CycloneDDS · ONNX Runtime · simulation-in-the-loop nonlinear least squares