# Calibrating the SO-101 Is Hardware QA
A practical SO-101 leader/follower calibration walkthrough, with measured ranges, benchmark comparisons, encoder-wrap failure modes, and the no-motion checks to run before teleop.
Authors: [Omar U. Espejel](/about/)
Published: 2026-06-01
Updated: 2026-06-01
Tags: robotics, lerobot, so101, calibration
## TL;DR

- Calibration is not a setup chore. It is the first hardware QA gate.
- For the follower arm, NVIDIA's SO-101 calibration stats are useful enough to catch bad ranges before motion.
- For the leader arm, use broad sanity bands and do not compare the handle directly to the follower gripper.
- Only wrist_roll should behave like a full-turn joint. If shoulder_lift reports a ~4050 range, redo it.
- After saving calibration, run a no-motion read test before teleop.

## The robot learning starts before the model

We calibrated a Hugging Face LeRobot SO-101 leader/follower pair.

The useful lesson was not:

> run the calibration command.

The useful lesson was:

> do not let a bad hardware state become your dataset.

If the motor IDs are swapped, the USB ports are confused, the middle pose is wrong, or a joint range crosses an encoder wrap, then teleoperation can still appear to work for a moment. But the data will already be contaminated. The model will later inherit an error that looked like setup trivia.

That is why calibration is hardware QA.

The [LeRobot SO-101 guide](https://huggingface.co/docs/lerobot/main/en/so101) says the calibration process makes the leader and follower arms share the same physical interpretation. The [NVIDIA SO-101 calibration guide](https://docs.nvidia.com/learning/physical-ai/sim-to-real-so-101/latest/07-calibrating-so101.html) says the same thing more operationally: without good calibration, control is inaccurate and can become unpredictable.

That framing is right.

Before teleop, before recording demonstrations, before imitation learning, the first artifact is a calibration file.

## The setup we actually had

Our hardware was a two-arm SO-101 setup:

- follower arm: the orange clamp/gripper arm, the one that will move during teleop;
- leader arm: the white no-clamp teleoperation arm, the one held by the human;
- both connected over USB-C to Feetech motor buses;
- LeRobot `0.5.1` with Python `3.12`;
- calibration saved under `~/.cache/huggingface/lerobot/calibration`.

The two serial ports were:

```text
leader   /dev/tty.usbmodem5B3E0892321
follower /dev/tty.usbmodem5B610364511
```

The serial mapping matters. NVIDIA explicitly warns that USB device assignments can change when cables are unplugged and replugged, and recommends identifying the teleop and robot ports before running commands. LeRobot also uses the `id` field as part of the calibration-file path, so the same physical setup should keep stable IDs when you calibrate, teleoperate, record, and evaluate.

For us, the stable IDs became:

```text
so101_leader_5B3E089232
so101_follower_5B61036451
```

The files landed here:

```text
~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/so101_leader_5B3E089232.json
~/.cache/huggingface/lerobot/calibration/robots/so_follower/so101_follower_5B61036451.json
```

That sounds like bookkeeping. It is not.

It is the first reproducibility boundary.

## What calibration is doing

LeRobot calibration has two conceptual steps.

First, put each joint in the middle of its physical range and press Enter. LeRobot uses that pose to establish homing offsets.

Second, move joints through their full safe ranges so the system can record minimum and maximum positions.

The [Feetech motor documentation in LeRobot](https://www.mintlify.com/huggingface/lerobot/motors/feetech) describes the three calibration fields that matter:

- `homing_offset`: the offset from motor zero to robot zero;
- `range_min`: the lower recorded safe position;
- `range_max`: the upper recorded safe position.

The simple version is:

```text
middle pose -> homing offsets
full joint sweeps -> range_min / range_max
```

The important operational detail is that the "middle pose" is not whatever looks aesthetically neutral.

It has to be the true middle of the joint's physical range.

We learned that on the leader arm.

The leader looked fine when it was lying too flat. But `shoulder_lift` kept recording fake ranges around `4050`, because the encoder crossed through `4095 -> 0`. That is not extra range of motion. That is a rollover artifact.

The fix was to physically recenter the shoulder:

1. move `shoulder_lift` toward one safe end stop;
2. move it toward the other safe end stop;
3. place it halfway between them;
4. only then press Enter and record ranges.

After that, `shoulder_lift` measured `2356`, which is exactly the kind of number we wanted.

## The table worth saving

The most valuable artifact from the session is the range table.

NVIDIA publishes follower-arm calibration statistics in the SO-101 guide. Their checker compares each captured range against a small calibration dataset and reports mean, standard deviation, and pass/fail. We used those follower stats as a benchmark for the follower arm.

For the leader arm, we did not invent official averages. The leader handle is mechanically different from the follower gripper, and the public stats table is for the follower. So the leader values below should be read as our measured reference plus sanity evidence, not as an NVIDIA benchmark.

| Joint | NVIDIA follower mean | Follower 2σ band | Our follower | Follower deviation | Our leader | Read this as |
|---|---:|---:|---:|---:|---:|---|
| shoulder_pan | 2725 | 2661-2789 | 2685 | -1.25σ | 2702 | both pass |
| shoulder_lift | 2350 | 2196-2504 | 2299 | -0.66σ | 2356 | leader fixed after wrap failures |
| elbow_flex | 2222 | 2204-2240 | 2204 | -2.00σ | 2202 | edge/pass on follower, clean leader |
| wrist_flex | 2329 | 2295-2363 | 2301 | -1.65σ | 2285 | both plausible |
| wrist_roll | 4026 | 3798-4254 | 4095 | +0.61σ | 4095 | full-turn joint |
| gripper / handle | 1475 | 1409-1541 | 1441 | -1.03σ | 1119 | leader handle is not follower gripper |

The follower result was close enough to trust:

```text
shoulder_pan   range=2685
shoulder_lift  range=2299
elbow_flex     range=2204
wrist_flex     range=2301
wrist_roll     range=4095
gripper        range=1441
```

The leader result became clean only after we fixed the middle pose:

```text
shoulder_pan   range=2702
shoulder_lift  range=2356
elbow_flex     range=2202
wrist_flex     range=2285
wrist_roll     range=4095
handle         range=1119
```

The asymmetry in the last row is the important part. The follower has a clamp/gripper. The leader has a handle. Comparing the leader handle directly to the follower gripper stat would be a category error.

## The failure mode to watch for

The clearest bug was encoder wrap.

Only `wrist_roll` should behave like a full-turn joint. In LeRobot's SO-101 calibration logic, `wrist_roll` is treated specially as the full-turn motor while the other joints get recorded min/max ranges.

That means this is normal:

```text
wrist_roll range=4095
```

This is suspicious:

```text
shoulder_lift range=4054
```

That second number is probably not a great shoulder sweep. It probably means the shoulder crossed the encoder boundary, so the recorder saw both a near-zero value and a near-4095 value.

If you save that range, your limits are wrong.

The bad capture looked like this:

```text
shoulder_lift min=0 max=4054 range=4054
```

The corrected capture looked like this:

```text
shoulder_lift min=849 max=3205 range=2356
```

This is why I prefer one-joint-at-a-time calibration with live range printing. The official flow works, but if you are new to the hardware, streaming one joint at a time makes mistakes visible.

The rule I would use:

> If any non-wrist-roll joint reports a range near the full encoder span, stop and redo before writing calibration.

The second rule:

> If a joint reports `range=0`, you moved the wrong joint or did not move enough.

We hit that too.

## The no-motion check before teleop

After calibration, do not jump straight to teleoperation.

Run a no-motion test.

The test is simple:

1. load the saved calibration file;
2. connect to the correct port;
3. disable torque;
4. read `Torque_Enable`;
5. read current positions;
6. disconnect.

For the follower, the passive read returned:

```text
connected: yes
torque_enable: all 0
raw_position:
  shoulder_pan   3584
  shoulder_lift   910
  elbow_flex     1499
  wrist_flex     1011
  wrist_roll     3628
  gripper        1959
disconnected: yes
```

For the leader:

```text
connected: yes
torque_enable: all 0
raw_position:
  shoulder_pan   3426
  shoulder_lift   857
  elbow_flex     1784
  wrist_flex      963
  wrist_roll     3815
  gripper        2047
disconnected: yes
```

This check does not prove teleop is safe.

It proves something narrower and useful:

- the port mapping is still right;
- all six motors respond;
- the calibration file loads;
- the bus can read positions;
- torque is disabled before motion.

That is the right stopping point before the first bounded teleop test.

## What I would record on video

If you are turning this into a tutorial, the video should not only show the robot moving.

Show the checks.

Record these clips:

1. The two arms connected and the port mapping.
2. The middle pose before pressing Enter.
3. One clean follower joint sweep.
4. One failed leader `shoulder_lift` wrap, if you can reproduce it safely, or at least the terminal output.
5. The corrected leader `shoulder_lift` sweep.
6. The final calibration table.
7. The no-motion read test with `torque_enable: 0`.
8. The next-day bounded teleop run.

The value is not a smooth montage.

The value is showing which evidence you trusted before the follower moved.

## The takeaway

Low-cost robot arms make robot learning feel accessible, which is good.

But the first serious problem is not the policy.

It is evidence.

Ports. Motor IDs. Calibration files. Middle poses. Encoder ranges. Torque state. Saved JSON. No-motion reads.

Once those are honest, teleop and data collection can begin.

Before that, the robot is only vibes with servos.
