Skip to content

Drive step 03 / 05 ~9 min read

at the endThe arm knows its folded home, its zeros and its joint windows without you holding anything, and you know why the numbers are what they are.

Calibrate

The arm finds its own folded home. Nothing to hold, nothing to eyeball.

Why this page exists: every angle the software shows or commands is sign * (servo_deg - zero_deg) per servo, and those two numbers per servo were wrong three times before they were right. This page says where they live, how the machine measures them, and what it cannot measure.

strands-arm calibrate --mode manual # torque OFF: you move each joint to both ends, fold, Enter -> real window + home
strands-arm calibrate --dry-run     # SimBus, nothing moves
strands-arm calibrate               # all six joints, bottom-up (1..6), writes home_deg into ~/.strands-arm/calib.json
strands-arm calibrate --joints 4,3,2

Manual first

Do this once on a new arm, before any search. --mode manual drops torque on every servo and samples positions at 20 Hz while you move each joint by hand to both ends of its real travel (head last: the Nicla's USB cable runs up the arm). Fold the arm and press Enter: the extremes become each servo's min_deg..max_deg window, the folded pose becomes home_deg, method manual. Nothing is ever commanded, so the numbers are the mechanism's own range, not a search result. --keep-home records the window only. Ctrl-C aborts and writes nothing. The dashboard shows the live table under state.calibrate.range and takes POST /api/control/calibrate/finish.

The search below runs joints 1 → 6, base first, head last. Head-first at the fold put the camera cable and the floor in the sweep.

How it finds a stop

A stop is where a joint stops moving while its goal keeps leading. Load is never the signal: the Nicla's USB cable, gear friction and gravity all raise load while the joint is still turning.

step value
search speed 25 °/s, torque limit 35 %
stall under 0.6° of travel in 0.5 s while the goal leads by 6°
confirm back off 3°, re-approach at half speed, stall again within 1°
home the stop, backed off 2° so the arm never rests on it
abort, torque off load over 60 % for 0.6 s, over 45 °C, bus under 4.3 V, or full travel with no stop

Joints 2, 3, 4 have one stop (the fold) and search toward it from 12° off home. Joints 1, 5, 6 have two stops and take the midpoint. A side that reaches an EEPROM limit or the 0/360 wrap leaves that joint unverified, home unchanged.

One file

~/.strands-arm/calib.json is the only calibration the software reads. One entry per servo id:

{"servos": {"4": {"zero_deg": 180.0, "sign": 1, "home_deg": 256.46, "min_deg": 84.29, "max_deg": 284.85,
                  "verified": false, "method": "eeprom_prep"}},
 "autocal": {"stops": {"2": [82.7], "3": [262.7]}, "standoff": 2.0, "mode": "refine"}}
  • zero_deg, sign: the servo to URDF map, q = sign * (servo_deg - zero_deg).
  • home_deg: the folded rest reading, what calibrate measures and the dashboard HOME drives to. One exception: a follower riding the printed body (robot.json arm_role: follower, base: true) rests in robot.FOLD_RAD["follower"] instead, elbow -1.10 rad, computed through this map; the bench fold would lower its gripper into the back wheel pod (hardware/cad/base/fitcheck_arm_fold.py). Joints 5 and 6 keep their home_deg.
  • min_deg, max_deg: the travel a hand measured in --mode manual; absent for a joint that turned all the way round.
  • verified, method: whether a machine measured the entry and how (design, zero_pose, sign_step, imu_step, imu_fit, autocal, eeprom_prep, home_json).
  • autocal: the last receipt, stops and standoff, kept beside the table.

Four readers, one file: kine.py (forward kinematics for shots and the viewer), guard.py (home and windows), autocal.py (writes home_deg), and the dashboard's 3D mirror through state.calib. That is why the sim moves 1:1 with the arm: there is no second table to drift. An older home.json next to it is imported once and renamed home.json.migrated. STRANDS_ARM_HOME moves the whole folder.

How the numbers got here

The table was wrong three times, each in a way worth knowing:

  • Signs from a vertical pose. A swing to vertical looks identical from either side, so joints 2, 3, 4 came out mirrored. A photo of the fold did not settle it either: the fold looks the same from either side in a still image.
  • Signs from the render. What settled it: all eight sign combinations rendered at the folded servo readings, and only +1/+1/+1 folds. The head IMU agrees: with the arm folded and the tilt turned 85°, FK under +1/+1/+1 sits 13° from the measured gravity vector, the old signs over 50°.
  • Head zeros on the wrap. Pan and tilt first read 357.2° and 3.9° at the fold, right on the 0/360 tick wrap. Servos 5 and 6 were re-indexed in EEPROM (homing offset, register 31) so the folded head reads 180.0 on both, and servo 6's gripper window 177.4..290.2 was opened.

servo_calib.DESIGN_MAP and the page fallback carry the result: zeros 180 on all six, signs +1, +1, +1, +1, +1, −1. Every entry is verified: false until a machine moves a joint and watches the IMU (--mode imu).

180 is a hypothesis

The zeros are 180.0 because LeRobot's lerobot-calibrate burns a homing offset into each servo so that the pose you hold during its middle step reads 2048 ticks. That pose is a hand's guess at "middle", not a geometric zero. When the arm was hand-held vertical the pitch joints read 184.0, 173.0 and 172.0 and tilt read 176.7: the size of that hand error. imu_fit.py carries both sets, LEROBOT and BENCH, as named hypotheses and lets the sensors pick.

The arm's own history, found

This arm's original LeRobot file, teleoperators/so_leader/leader.json from 2026-07-26, was found on the training box and is kept at hardware/calib/lerobot_leader_2026-07-26.json (with the follower's so101.json). It is provably this arm: its hand-swept ranges are exactly the EEPROM windows read off the servos six weeks later, base 1268..2862 ticks = 111.4°..251.5°, shoulder lift 940..2147 = 82.6°..188.7°, elbow 778..2992 = 68.4°..263.0°. Two things follow. The fold stop is a rock: LeRobot's shoulder lift minimum 82.6° and autocal's stop 82.7° agree to 0.1° across six weeks and a new head. And the fold now rests the head on the forearm at about 257°, 28° short of the wrist's own stop at 284.9°: the hand found the metal, the stop search found the head.

What autocal measures, and what it cannot

calibrate finds stops. Joints 2, 3, 4 have one stop each, the fold; joints 1, 5, 6 have two or none. A stop gives a home_deg. It does not give a zero or a sign.

calibrate --mode imu adds the Nicla. The IMU sees gravity, so it knows the head's absolute pitch; kine.lens_attitude(q) predicts that pitch from the joint angles under a candidate table, and the table with the smallest RMS gap wins (imu_fit.py). Stepping one joint at a time and watching which way the pitch moves gives that joint's sign (imu_step.py).

Honest limits, in the code as well as here:

  • Joints 2, 3, 4 and 6 turn about parallel axes, a planar chain. Pitch constrains the sum of their zero errors, not each one; two tables with the same signed sum tie, and imu_fit.ties() says so instead of guessing. Lens height from the ToF over the table breaks the tie; pitch alone never will.
  • Base yaw (servo 1) has no gravity reference. Its zero, "facing forward", is a convention. Its sign needs the IMU's yaw rate while the base turns, or you.
  • Pan (servo 5) likewise: two soft ends, no gravity, no stop. Home 180.8 is where the head hung after the re-index.
  • A stop search that reaches an EEPROM limit or the wrap leaves the joint unverified, home unchanged.

What the hand found (--mode manual)

The automatic search in refine mode explores ±12° around the fold. It finds the fold. It is blind to everything else, and a hand-moved pass shows how much else there is:

id joint servo min..max span URDF q (live signs) model lower..upper note
1 shoulder_pan 66.4..251.0 185° −114..+71 ±110 asymmetric; EEPROM window 111..251 throws away 45° on the minus side
2 shoulder_lift 83.2..290.0 207° −97..+110 ±100 fold (83.8) IS the minus stop
3 elbow_flex 68.7..262.4 194° −111..+82 ±97 fold (262.4) IS the plus stop
4 wrist_flex 84.3..284.9 201° −96..+105 ±95 matches the EEPROM window 83.7..285.7 to 0.6°; the fold (256.5) is 28° short of the stop — autocal's "stop" at 255.8 was the head resting on the arm, a soft stop, which its log said (let go, continuing)
5 wrist_roll (pan) — 345° — −157..+163 free-spinning horn; only the Nicla's USB cable limits it
6 tilt — 508° — ±90 1.4 turns by hand; free-spinning horn, no mechanical stop

Consequences, and where each lives:

  • Pan and tilt have no window. Spans ≥ 350° are reported continuous and write nothing (MANUAL_CONTINUOUS_DEG). What limits them is the cable and the guard's head window (head_window(), ±160° pan, ±80° tilt from the calibrated zero), not metal.
  • The model's symmetric ±ranges are wrong for 1, 2, 3. The real joints are asymmetric about the LeRobot mid-range zero. The sim's SERVO_LIMITS (rl/envs/frame_env.py) use the EEPROM windows, not the measured ones, so a policy trained there never sees the extra travel.
  • The base EEPROM min (111.4) is conservative. calibrate --open-limits 1 --i-know opens it; do it only if the cable run allows the extra 45°.
  • Homes moved a little. The hand-folded pose (176.7 / 83.8 / 262.4 / 256.5 / 187.8 / 180.3) differs from the stop-derived one (179.0 / 84.7 / 260.8 / 253.8 / 180.8 / 178.7) by up to 7° on pan. For 2/3/4 the search's stop-minus-standoff is the more repeatable home; for 1/5/6 neither is verified. Run the search again after a manual pass: it keeps the windows and refines the homes.

EEPROM, once

The head servos ship with a gripper's angle window. calibrate fixes the servo itself:

strands-arm calibrate --open-limits 6 --rezero 5,6 --no-search --i-know

Opens the MIN/MAX window to 0..4095 and makes the current pose read 180.0, away from the 0/360 wrap. Nothing moves. After this both pan (5) and tilt (6) read 180 at the folded home.

From the dashboard

POST /api/control/calibrate runs the same code as a job. Progress shows in the header; every other control is locked until it ends. /api/control/calibrate/abort stops it with a verified torque off.

STRANDS_ARM_HOME moves both files somewhere else.