30 min

Calibration: making two arms mean the same thing

Calibration is the map from raw encoder counts to joint angles, and its real job is agreement between everything that quotes those numbers rather than accuracy against the physical world.

Where you are. Your arm is assembled and its six motors answer to ids 1 through 6 on one bus. Those motors already report numbers. This lesson gives the numbers a meaning, and shows why getting the meaning slightly wrong produces a silent miss rather than an error.

Two builders, one number, two different arms

Two people build the same kit from the same box on the same afternoon.

Both reach the elbow. Both press the printed link onto the splined output shaft of the servo, and a spline seats at one of a fixed set of angles, not anywhere you like. Neither builder thought about which one. They pushed until it clicked and moved on.

Now both arms are powered. Both elbow motors report the same number: 2048. On one arm the forearm sits level with the desk. On the other it is tipped down by eleven degrees.

Nothing is broken. Nothing will throw. Both motors are telling the exact truth about their own output shafts, to a fraction of a degree, and both numbers are useless until somebody says what 2048 is supposed to mean.

The idea in one paragraph

Calibration is the map between the raw counts a motor reports and the joint angles that everything else in your stack assumes. It is two numbers per joint: a homing offset that decides which count reads as zero, and a range that says which counts you can actually reach. LeRobot writes both into the motor’s own memory and into a JSON file named after a string you choose. Here is the part people get wrong: calibration is not about being right. There is no true zero on this machine, and nothing measures the arm against the world. Calibration is about agreement. The leader and the follower, the dataset you record and the policy you deploy, and any kinematics you write by hand all have to be quoting the same map. When two of them disagree, the arm does not fail. It misses, by the same amount, in the same direction, every single time, which is exactly the systematic error Module 1 warned you about.

what the motor reports · register 56 0 … 4095 raw counts, one full turn range_min your 'middle' range_max What calibration writes, per motor homing offset (reg 31) · which count reads zero range min / max (regs 9, 11) · the ends you can reach degrees, with zero where you put it unreachable unreachable the gripper is the exception: always normalised 0 … 100, never degrees
Calibration is a map: raw encoder counts on top, the two numbers calibration writes in the middle, and the joint-angle scale your code sees at the bottom, clipped to the range you swept

Wider than the screen; scroll it sideways.

What the motor actually knows

Inside each STS3215 is a magnetic absolute encoder that splits one turn into 4096 counts. Absolute means it knows where it is the instant it powers on, with no homing routine, which is a genuine luxury; incremental encoders would make you re-home the arm every time you plugged it in.

One count is 360/4096=0.088360 / 4096 = 0.088 degrees. That resolution is not going to be your problem. Read Present_Position (register 56) and you get an honest, high-resolution answer to a question nobody asked: how far round is this particular output shaft, measured from a zero the factory chose.

The two numbers, and the two prompts that set them

lerobot-calibrate --robot.type=so101_follower --robot.port=<PORT> --robot.id=my_follower
lerobot-calibrate --teleop.type=so101_leader  --teleop.port=<PORT> --teleop.id=my_leader

Run it and the procedure disables torque, puts every motor into position mode, and then asks you two things.

The first prompt asks you to move the arm to the middle of its range of motion and press ENTER. At that instant it records where every motor is and computes the homing offset that makes that pose read as mid-scale. You are defining zero with your hands. How carefully you place the arm is how good your calibration is; there is no other input.

The second prompt asks you to move every joint except wrist_roll through its entire range. As you sweep, it records the smallest and largest count each motor visited, and those become the joint’s limits. Sweep timidly and you will hand the robot an arm that is shorter than the one on your desk.

The result per motor is five values: the motor id, a drive mode, the homing offset, and the two range ends. The gripper is normalised separately, always to a 0 to 100 scale rather than degrees, because “how open is it” is a more useful unit than “what angle is the finger linkage at”.

The numberRegisterSet byWhat goes wrong when it is wrong
Homing offset31the “move to the middle” promptevery angle on that joint is shifted by a constant, forever
Range min / max9, 11the sweepthe arm refuses part of its own workspace, or is allowed to drive into its own frame
Gripper normalisation-fixed, 0 to 100nothing; it is not yours to set

Where it lives, and why the name is a primary key

Calibration is written to two places at once: into each motor’s EEPROM, where it survives power-off and travels with the motor, and into a JSON file on your laptop.

The file lands under your Hugging Face cache:

~/.cache/huggingface/lerobot/calibration/robots/so_follower/<your-id>.json

The --robot.id string is not a label. It is the key. Calibrate as my_follower and then teleoperate as my-follower, and LeRobot looks for a file that does not exist, finds no calibration, and starts the calibration procedure at you. Nothing warns you that you had one.

Motor EEPROM written into each servo survives power-off Calibration file ~/.cache/huggingface/lerobot/ calibration/robots/so_follower/<id>.json connect() compares them every single time They agree the arm runs on your numbers They differ, or it is gone LeRobot quietly re-calibrates a typo in --robot.id looks the same
Calibration is written to the motors and to a file at the same time, and every connect compares them: agreement runs, disagreement quietly recalibrates

Wider than the screen; scroll it sideways.

Calibration is agreement, not accuracy

Now the part that reframes everything.

Suppose your follower’s shoulder homing offset is three degrees off. The arm believes it is at zero when it is actually at three. Does your policy care?

Often, less than you expect. If you record demonstrations and deploy the policy on the same arm under the same calibration, the offset is a constant applied to both sides of the problem. The camera saw the real arm; the recorded joint numbers were consistently three degrees out; the policy learned the mapping between what the camera showed and what the numbers said. That constant folds into the learned function and mostly cancels.

It stops cancelling the moment two parties disagree.

Calibrate you set the map once, per arm Teleoperate leader and follower share one map Record every frame stamped in these units Deploy the policy emits these same units Re-map at any point and everything downstream moves same arm, same id, same file, from the first calibration to the last eval re-calibrating after recording quietly re-labels the dataset
The four stages that must quote the same calibration: calibrate, teleoperate, record, deploy

Wider than the screen; scroll it sideways.

Four places where the disagreement actually arrives:

  • Leader against follower. Teleoperation maps the leader’s joint angles onto the follower’s. Two calibrations, two definitions of zero, and the follower sits at a constant offset from your hand. You will compensate with your wrist without noticing, and teach the policy your compensation.
  • Dataset against deployment. Re-calibrate after recording and every joint number in that dataset now refers to a coordinate system the robot no longer uses.
  • Model against machine. Anything you wrote in Module 1 that takes joint angles and computes where the hand is assumes the angles are true. Forward kinematics stays perfectly self-consistent while describing a robot slightly different from the one on your bench.
  • Replay against reality. lerobot-replay sends recorded joint positions straight back to the arm. Under a different calibration, the arm confidently traces a shifted version of the trajectory, into the table.

The error you will actually see

Module 1 gave you the rule: an angle error at a joint becomes a position error at the tip of roughly rΔθr\,\Delta\theta, where rr is the distance from that joint to the gripper and Δθ\Delta\theta is in radians. Put your own numbers in.

For a joint with 25 cm of arm beyond it:

Δx0.25×π180×Δθdeg=4.4 mm per degree\Delta x \approx 0.25 \times \frac{\pi}{180} \times \Delta\theta_{\text{deg}} = 4.4\ \text{mm per degree}

So one encoder count of error, 0.088 degrees, costs you 0.4 mm. Eyeballing “the middle of its range of motion” to within three degrees, which is roughly what an unhurried human does without a reference, costs you 13 mm. The encoder is not your error source. Your judgement of the middle is, by a factor of about thirty.

When to re-calibrate, and when to refuse

EventRe-calibrate?
Replaced a servoYes. The new motor has a factory zero and no history.
Hard collision, or the arm was forced past a limitYes. Check whether a horn slipped on its spline.
Took a link off and re-seated itYes. You just re-rolled the assembly accident from the hook.
Changed the --robot.id stringNo. Restore the string, or copy the JSON to the new name.
Moved a camera, changed the lightingNo. Different subsystem entirely.
About to deploy a policy you trained last weekNo. This is the expensive one. Re-calibrating now silently re-labels the coordinate system your dataset was recorded in.

Without hardware

A calibration’s output is visible in any public dataset, because every stored joint angle passed through one.

  • Instead of calibrating, download lerobot/svla_so101_pickplace and take the per-joint minimum and maximum of observation.state across all 11,939 frames. Checked on 10 August 2026 the minima run [-92.7, -98.9, 16.8, 34.0, -92.3, 1.2] and the maxima [88.1, 8.8, 99.4, 98.6, -20.2, 32.7].
  • Measure this: the offset and scale that would map those six ranges onto the joint limits in your Module 2 MJCF. Those are exactly the two numbers per joint this lesson is about, recovered from data rather than from an arm.
  • What you lose: drift. Nothing in a downloaded file moves overnight, so the failure this lesson exists for - a dataset that quietly stops meaning what it meant - cannot be reproduced on this path.

Check yourself

1. Two motors both report 2048 and the two arms are in visibly different poses. Which of the two calibration numbers fixes that, and what does it physically do?

The homing offset, register 31, set by the “move to the middle of its range” prompt. It is a constant added to the raw count so that the pose you chose reads as mid-scale. It does not move anything and it does not change what the encoder measures; it changes the interpretation, so that the same physical pose on both arms produces the same reported angle.

2. Your follower’s shoulder homing offset is two degrees out. You record 60 episodes and train a policy on that arm. Why might the policy work anyway, and what exactly would break it?

Because you recorded and deployed under the same map. The offset is a constant on both the input and the output side, the cameras saw the real arm throughout, and the learned function absorbs the constant. It breaks the moment something else quotes a different map: re-calibrating between recording and deployment, replaying the dataset on a differently calibrated arm, teleoperating from a leader whose zero disagrees, or feeding those angles to forward kinematics that assumes they are true.

3. You calibrate as --robot.id=my_follower and later teleoperate as --robot.id=follower. What happens?

LeRobot looks for a calibration file named follower.json, does not find one, concludes the robot is uncalibrated and runs the calibration procedure. Your original numbers are still on disk and still in the motors’ EEPROM; they are simply unreachable, because the id string is the key that selects them. This is why the id gets chosen once and written down.

4. During the sweep you move wrist_roll through its whole range like every other joint. What does that accomplish?

Nothing. wrist_roll is excluded from the sweep and hardcoded to the full turn, 0 to 4095, because it is a continuous-rotation joint with no meaningful ends. The prompt says “all joints except wrist_roll” and means it.

5. A grasp fails by about 3 cm to the left, on every single attempt, for two days. Where do you look first, and what is the reasoning?

Calibration, or something else geometric such as a shifted camera or a slipped horn. The reasoning is the consistency: a learned policy failing for perceptual reasons fails variably, with the error depending on lighting, object position and pose. An error that is the same size in the same direction every time is a constant, and constants come from the map between numbers and the world, not from a network. Since one degree at 25 cm is about 4.4 mm, 3 cm is roughly seven degrees somewhere, which is a large enough error to find by hand.

6. Why is it fair to say calibration on this arm has nothing to do with accuracy?

Because nothing in the procedure measures the arm against the physical world. You nominate a pose by hand and call it the middle; you sweep by hand and call the extremes the limits. There is no metrology anywhere. What the procedure buys is a shared, reproducible convention that the leader, the follower, the dataset and the policy can all quote. Agreement is the deliverable; accuracy would need an external measurement the kit does not have.

Do this

About forty minutes with the arm on the bench. Every number here is one you produce, not one this lesson can produce for you.

1. Choose your ids and write them down. One line in a file you keep in your project repository, for example follower_id=kp_follower, leader_id=kp_leader, plus the two serial ports. You will paste these strings for the next six weeks.

2. Calibrate the follower, slowly. Before pressing ENTER on the middle-of-range prompt, set up a physical reference: a book on edge against the upper arm, a level app on the flat of a link, anything reproducible. Then sweep every joint except wrist_roll to its genuine mechanical ends, twice, gently. Stop when you feel resistance, not after it.

3. Calibrate the leader the same way, with the same care.

4. Back both files up into your repository.

mkdir -p calibration-backup
cp -r ~/.cache/huggingface/lerobot/calibration/ calibration-backup/

That directory is now the only copy of a hand-made artefact that took you forty minutes. Commit it.

5. Read your own numbers. code/calibration_check.py prints the map: per joint, the homing offset, both range ends, and the swept span converted to degrees. Look for a joint whose span is much narrower than the others. That is a sweep you cut short.

python code/calibration_check.py --file ~/.cache/huggingface/lerobot/calibration/robots/so_follower/kp_follower.json

6. Build your own error budget. Measure, with a tape, the distance from each joint axis to the gripper on your arm. Feed them in:

python code/calibration_check.py --budget 0.30,0.26,0.17,0.08,0.05,0.03

It prints millimetres of tip error per degree of calibration error at each joint. Two questions to answer in one sentence each: which joint would you calibrate most carefully, and how many degrees of error would it take to produce the 3 cm miss from question 5?

7. See the failure on purpose. Re-run the calibration under a throwaway id such as kp_follower_sloppy, and this time set the middle pose deliberately badly, off by an obvious amount at the shoulder. Then diff the two JSON files. The difference in homing offset is your mistake in counts; multiply by 0.088 to get degrees, and by your own arm length to get millimetres. Delete the throwaway file afterwards and confirm your real id still loads.

What you can now do

You can calibrate both arms and say precisely what the procedure wrote and where: a homing offset and a range per motor, into EEPROM and into a JSON file keyed by an id string you chose. You can explain why the gripper and wrist_roll are special cases, why calibration buys agreement rather than accuracy, and which four disagreements actually hurt. And you can recognise a calibration error by its signature - the same miss, in the same direction, every time, with nothing in the logs - and convert it into degrees with arithmetic you can do on paper.

What you can now do

You can calibrate both arms, say exactly what the two numbers per joint change, find and back up the file they live in, and predict the precise shape of the error a bad calibration produces.