Calibration

We record each joint’s physical limits, check that travel is a normal encoder interval, and map those ranges and directions onto the sim model.

Do this on first hardware bring-up, after replacing a servo, or when travel changed.

Before you start

  • Prepare each servo with solarian calibrate-servo before you mount it: it sets the ID, resets the comms and position registers, and parks the shaft at mid-encoder so you can fit the horn with the mechanism at mid-travel. That keeps the 0/4095 encoder seam out of the joint's travel, which is the one thing calibration cannot fix in software.
  • Assign servo IDs and the serial port in conf/modules/<module>.yaml first — solarian setup-module if they are not already set. Servo model and continuous-vs-limited live on the Module class, not in yaml.
  • Stop run-robot. The bus can only be open once; every calibrate command talks to the device in-process, the same way solarian calibrate does.
  • Run on the machine the device is wired to (the Pi under a pi deployment; your laptop under localhost).
# conf/modules/right_arm.yaml
actuators:
  shoulder_pan:
    id: 1
    port: ${actuator_port}
    # written by solarian calibrate:
    # real_range_min / real_range_max / center_offset / drive_mode

Calibrate an arm (SO-106)

Example: the SO-106 project, module right_arm.

solarian calibrate   # pick right_arm

The console uses the same step titles as this page. Answer ENTER / Y / N in the sim window when it is open (the terminal also works).

Step 1: Joint limits

Move every hard-stop joint through its full travel. Continuous joints (wrist_roll on SO-106) are printed and skipped — they have no hard stop.

The status board shows MIN / POS / MAX. POS is the raw encoder. A single wrap-looking glitch is ignored.

Step 1: Joint limits status board

If travel crosses the encoder seam

Travel that straddles 4095→0 is rejected, and the command walks you through fixing it on the spot. No offset can help: the servo computes Goal - Present in raw counts with no wraparound, so a goal on the far side of the seam drives the wrong way into a hard stop and overloads. An offset — software center_offset or the hardware Homing_Offset register — is a translation, so it moves the discontinuity without removing it.

The only fix is to rotate the horn on the shaft:

  1. Remove the horn / linkage from the joints the command lists.
  2. Confirm the shaft turns freely; the command then drives it to mid-encoder (2048), which can be most of a turn.
  3. Refit the horn with the mechanism at mid-travel (half open, for a gripper).
  4. The limit sweep restarts, and now saves center_offset: 0.

Step 2: Home position

The sim opens parked at model q=0 — not a live copy of the real arm (mapping is not done yet). Match that pose by hand. It is mid-travel for hard-stop joints and the zero we store for continuous wrists. Leave a little room so any joint can still move a few degrees either way.

Step 2: Home position — sim at model zero

Press ENTER in the sim window when the real arm matches.

Step 3: Sim mapping

The sim now follows the real arm. For each joint a red arrow marks the hinge axis. Move that joint. If the sim goes the other way, answer N and we flip drive_mode.

Step 3: Sim mapping — highlighted joint

This is the only direction question. There is no “which encoder end is min.”

Output

Values are written to conf/modules/right_arm.yaml and to the servos (or applied in software on buses with no homing register).

Output: saved calibration fields

FieldMeaning
real_range_*Travel window in encoder counts
center_offsetEncoder-zero shift. 0 after a sweep; non-zero only from set_half_turn_homings on a continuous joint
drive_modeSign vs the model (0 or 1)
homeNormalized pose you matched. Not move.yaml poses.home
# conf/modules/right_arm.yaml — after solarian calibrate
actuators:
  shoulder_pan:
    id: 1
    port: ${actuator_port}
    real_range_min: 452
    real_range_max: 3255
    center_offset: 0
    drive_mode: 0
home:
  shoulder_pan: 2.4
  # ...

After save, the sim stays open and follows the real arm. On-screen nrm is the real normalized pose; rad is the sim joint angle. Move the arm, then press ENTER when you are done.

Check spans later:

solarian verify-calibration

Calibrate a gripper

Same Step 1 (one actuator). Then one physical low-command pose (typically fully closed) — no sim mapping. That fixes the servo's travel; it says nothing about how far the jaws open along it. Run solarian calibrate-gripper-ranges to sweep the servo and record a ruler reading (mm) at each stop, which is saved as aperture_table and interpolated at runtime.

Using the module

Robot.connect() loads the yaml fields and writes them onto the servos. If a hard-stop actuator is missing real_range_* it runs Step 1 only (no IK viewer). Re-push without sweeping: solarian write-robot-calibration.

Do not re-run solarian calibrate unless you replaced a servo whose travel changed.

Model authors

The MuJoCo jnt_range must equal the real hard-stop angles. Continuous joints stay 0-centered in the model (e.g. ±π). solarian derive-model-limits prints MJCF-ready ranges. solarian verify-calibration compares spans, then opens the live mirror.

Calibrate Latencies

A UMI skill needs observation and action delays measured on this hardware. Stop run-robot. Each command writes its results into conf/modules/<module>.yaml. Full scheme: Latency & Synchronization.

  1. solarian calibrate-arm — mode 1 (all): max_speed, then action_latency, then receive_latency.
  2. solarian calibrate-gripper — mode 1 (all), same three.
  3. solarian calibrate-camera-latency — display role on the monitor machine, capture on the robot.

The speed has to come before the latency, which is why all exists: a sinusoid that exceeds max_speed is stretched by the interpolator and measures the speed cap instead of the pipeline.

See also

Was this page helpful?