so101-doctor

A free tool for the SO-101 arm. It checks your six servos and writes a MuJoCo twin (a simulated copy) for the servo gain your arm runs. On our arm the twin rests about twice as close to the real arm as the stock model (one arm, not yet replicated). Read only: it cannot move the arm or change a setting.

macOS or Linux
# install (Python 3.10 or newer)
$ python3 -m venv ~/so101
$ ~/so101/bin/pip install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0
# check the arm and write its twin (works without an arm)
$ ~/so101/bin/so101-doctor --export-mujoco ~/so101_twin
# open the twin
$ ~/so101/bin/python -m mujoco.viewer --mjcf ~/so101_twin/scene.xml
Windows (PowerShell)
# install (Python 3.10 or newer)
> py -m venv "$HOME\so101"
> & "$HOME\so101\Scripts\pip" install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0
# check the arm and write its twin (works without an arm)
> & "$HOME\so101\Scripts\so101-doctor" --export-mujoco "$HOME\so101_twin"
# open the twin
> & "$HOME\so101\Scripts\python" -m mujoco.viewer --mjcf "$HOME\so101_twin\scene.xml"

New to this? (me too) Step by step. For AI agents.

Start here

Pick your computer. Paste each command into a terminal. Needs Python 3.10 or newer.

  1. Install

    macOS or Linux
    python3 -m venv ~/so101
    ~/so101/bin/pip install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0
    Windows (PowerShell)
    py -m venv "$HOME\so101"
    & "$HOME\so101\Scripts\pip" install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0

    Ends with “Successfully installed”.

  2. Check your arm

    Arm powered, USB plugged in, LeRobot closed.

    macOS or Linux
    ~/so101/bin/so101-doctor
    Windows (PowerShell)
    & "$HOME\so101\Scripts\so101-doctor"

    Starts with “6 of 6 servos read”. Nothing on the arm is changed. No arm: add --demo. Trouble: Fixes.

  3. Write the twin

    macOS or Linux
    ~/so101/bin/so101-doctor --export-mujoco ~/so101_twin
    Windows (PowerShell)
    & "$HOME\so101\Scripts\so101-doctor" --export-mujoco "$HOME\so101_twin"

    Writes the twin for the P gain it read, 16 or 32. No arm: P 16, or add --twin-p 32.

  4. Open the twin

    macOS or Linux
    ~/so101/bin/python -m mujoco.viewer --mjcf ~/so101_twin/scene.xml
    Windows (PowerShell)
    & "$HOME\so101\Scripts\python" -m mujoco.viewer --mjcf "$HOME\so101_twin\scene.xml"

    A window opens with the arm. The sliders on the right move the joints.

Checked: every step on Ubuntu 24.04 (step 4: the window opened, sliders not tried) and step 2 on macOS. Not yet: Windows, steps 3 and 4 on macOS. Stuck? Open an issue. Worked? A star on GitHub helps.

Vato’s twin

MuJoCo Menagerie’s SO-101 with the five arm servos replaced by a model of the real STS3215: P loop on motor voltage, 12 V limit, back-EMF, acceleration limit. One plain MuJoCo file, for P 16 (what LeRobot writes) or P 32 (the factory value).

  1. scene.xml: the arm on a floor. so101_twin.xml: the arm alone. values.json, README.md: the nine servo values and their sources.
  2. ctrl is the joint goal in radians, as on the servo. The gripper keeps Menagerie’s actuator.
  3. Angles are Menagerie’s. LeRobot’s zero depends on your calibration: do not copy joint numbers between the two.

From Python: save as try_twin.py, run ~/so101/bin/python try_twin.py& "$HOME\so101\Scripts\python" try_twin.py. The P 16 twin prints about 0.29, -0.48, 0.41, 0.30, -0.19.

import os
import mujoco

path = os.path.expanduser("~/so101_twin/scene.xml")  # from step 3
m = mujoco.MjModel.from_xml_path(path)
d = mujoco.MjData(m)
# joint goals in rad: pan, lift, elbow, wrist flex, wrist roll
d.ctrl[:5] = [0.3, -0.5, 0.4, 0.3, -0.2]
for _ in range(6000):  # 3 s at the file's 0.5 ms step
    mujoco.mj_step(m, d)
print(d.qpos[:5].round(3))  # where the joints are now
How far the simulated gripper tip rests from the real one, on our one arm. Lower is better.
Arm set toVato’s twinStock MenagerieHoldsStatus
P 166.4 mm15.2 mm180Not yet replicated
P 323.7 mm7.7 mm164Not yet replicated
Same commands to the arm and the simulation. Mean distance between the two gripper tips at rest, from the joint encoders, not a camera. A hold is one pose the arm rested at. P 16: 8 Nov 2026. P 32: 10 Nov 2026.

One arm, nothing in the gripper, one session per row. In motion at P 16 the twin tracked the joints to 1.25° RMSE against 2.23° for stock Menagerie, closer on every joint of five replayed teleoperation recordings (same arm, recorded 2 Nov 2026, not yet replicated). Stock Menagerie is robotstudio_so101 at repository commit f3b6013 (23 Oct 2026).

Not checked: a second arm, a load in the gripper, the 7.4 V servo version, other or mixed P gains, fast teleoperation at P 32. Scored on MuJoCo 3.17.0 (only the 2.23° was computed on 4.0.0); on 4.0.0 the twin loads and one 3 s test move ended at the same joint angles.

Servo torque law: BAM (Rhoban, arXiv 2410.08650). Body, meshes and gripper: Menagerie (derived from The Robot Studio’s SO-ARM100 model).

What it prints

One row per servo, then warnings in plain words. With --export-mujoco: one more line per servo setting that differs from the twin’s.

so101-doctor --demo Example values
$ so101-doctor --demo
so101-doctor: 6 of 6 servos read on DEMO (example values, not a real arm) (scan complete)
id  joint         fw    volts temp  P/D/I      acc max85  accel rad/s2  60 deg min  note
----------------------------------------------------------------------------------------
 1  shoulder_pan  3.10   12.0  30C  16/32/0    254   254          39.0      0.33 s
 2  shoulder_lift 3.10   12.0  30C  16/32/0    254   254          39.0      0.33 s
 3  elbow_flex    3.10   12.0  30C  16/32/0    254    50           7.7      0.74 s  capped by Maximum_Acceleration 50
 4  wrist_flex    3.10   12.0  30C  16/32/0    254   254          39.0      0.33 s
 5  wrist_roll    3.10   12.0  30C  16/32/0    254   254          39.0      0.33 s
 6  gripper       3.10   12.0  30C  16/32/0      0   254           7.7      0.74 s  Acceleration 0 acts as 50
WARNING  servo 3 (elbow_flex): Acceleration 254 asks for 39.0 rad/s^2 but Maximum_Acceleration (register 85) = 50 caps it at 7.7 rad/s^2: 5.1x lower, so a move from rest takes up to 2.3x longer
WARNING  joints 1-5 differ in maximum_acceleration: [50, 254]. Joints set up differently move differently.
note     Acceleration (register 41) reads 0 on servo 6. The register is not stored: it goes back to 0 each time the servo is powered. LeRobot writes 254 when it connects, so this is the arm as it is now, not as LeRobot runs it.

Read only: nothing was written to the servos. Method and measurements: https://github.com/VatoLabs/STS3215_profile

Example values, legend left out. accel: the acceleration limit the firmware applies. 60 deg min: the shortest time it allows for a 60° move. What to do about each warning: table below.

If something is off

What you might see, and what to do.

You seeWhat to do
“No USB serial port found”Check the USB cable and the power supply. --list-ports shows what the computer sees.
It lists several ports and asks for --portMore than one servo board is plugged in, or none looks like the usual board (USB vendor 1a86). Pass the arm’s port: --port /dev/ttyACM0 (Linux), --port /dev/cu.usbmodem... (Mac) or --port COM3 (Windows).
“Permission denied” on LinuxRun sudo usermod -aG dialout $USER, then log out and back in.
The port is busy, or no servo answersQuit LeRobot and other arm scripts: only one program can use the port. Check the servos have power; USB alone does not power them.
Acceleration 0 on every jointNormal right after power-on. Connect with LeRobot once (it writes 254), close it, run so101-doctor again.
“capped by Maximum_Acceleration”Register 85 limits that joint below what register 41 asks for, so it moves slower than the others. LeRobot writes 254 to both when it connects.
P 16 on some joints and 32 on othersNot tested with the twin. You get the P 16 twin and a note. Set joints 1 to 5 all to 16 or all to 32.
About 7 V instead of 12 VYou have the 7.4 V servo version. The check works; the twin is not made for it yet.
“holds the twin for P 32, not P 16: pass a new folder”, or the folder is not emptyNothing is overwritten. Give --export-mujoco a new folder name.
Python is missing, or pip cannot find mujoco 3.17.0You need Python 3.10 or newer. Install it from python.org, open a new terminal and redo step 1.
“ensurepip is not available” (Debian, Ubuntu)Run sudo apt install python3-venv, then redo step 1.
On a Mac, your own script with launch_passive failsRun it with ~/so101/bin/mjpython script.py. Step 4 does not need this.

Why the acceleration matters

The STS3215 does not drive straight to the goal. It moves an internal target toward it at a constant acceleration, a = 100 steps/s² × min(register 41, register 85) (register 41 at 0 acts as 50), and the position loop follows that target. A simulator without this profile chases the goal itself.

Mean position error against the real servo, on a held-out 0.58 N·m load. Lower is better.
  1. Stock MuJoCo Menagerie actuator68.3 mrad 3.9°
  2. BAM’s STS3215 model, goal as sent18.1 mrad 1.0°
  3. Same model, goal through the profile7.1 mrad 0.4°

One bench servo at LeRobot’s settings, 30 Oct to 1 Nov 2026. The 68.3 to 18.1 step is BAM’s model (Rhoban), fitted by us only on T-K-233’s public logs; the profile’s share is 18.1 to 7.1. On six servos at one light load: 18.4–18.9 mrad down to 5.2–7.4. T-K-233 first showed register 85’s role in Rhoban/bam PR #20. Method, limits and credits: VatoLabs/STS3215_profile.

Read only

so101-doctor sends the servos PING and READ and nothing else, and its tests check that. It cannot write a register, so it cannot move the arm, change a setting or switch torque. Source: VatoLabs/so101-doctor.

For AI agents

Setting this up for someone? This block is the whole job. Same text: vatolabs.com/llms.txt.

so101-doctor 0.1.4: check an SO-101 arm's servos (read only) and export its MuJoCo twin.
Needs: Python 3.10+, internet for the install. Linux: the user must be in group dialout.

macOS / Linux
  python3 -m venv ~/so101
  ~/so101/bin/pip install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0
  ~/so101/bin/so101-doctor --json                         # read the arm; add --demo when no arm is connected
  ~/so101/bin/so101-doctor --export-mujoco ~/so101_twin   # a new or empty folder; works with no arm connected
  ~/so101/bin/python -m mujoco.viewer --mjcf ~/so101_twin/scene.xml

Windows (PowerShell)
  py -m venv "$HOME\so101"
  & "$HOME\so101\Scripts\pip" install https://github.com/VatoLabs/so101-doctor/releases/latest/download/so101-doctor.zip mujoco==3.17.0
  & "$HOME\so101\Scripts\so101-doctor" --json                              # as above: add --demo when no arm is connected
  & "$HOME\so101\Scripts\so101-doctor" --export-mujoco "$HOME\so101_twin"
  & "$HOME\so101\Scripts\python" -m mujoco.viewer --mjcf "$HOME\so101_twin\scene.xml"

Facts
- Read only: the tool sends PING and READ packets and nothing else. It cannot move the arm, write a register or switch torque.
- It needs the serial port to itself: ask the user to quit LeRobot first. One board with USB vendor 1a86 is picked automatically; otherwise pass --port.
- --export-mujoco reads the P gain of servos 1 to 5 and writes the twin for it (16 or 32). No arm: the P 16 twin. --twin-p 16 or --twin-p 32 chooses by hand.
- --json prints one JSON object: version, source, complete, bus_error, servos, findings (level, text) and, with --export-mujoco, twin (exported, p, p_source, checked, reason, match, mismatches, mixed_p, assumes). p_source is arm, option (--twin-p), default (no arm read, mixed gains, or a P other than 16 or 32) or demo.
- Exit codes: 0 report printed, 1 nothing could be read (port busy, no servo answered, or no port found), 2 bad command line or an export folder that holds other files or the other P's twin (the same twin again is left as it is and is not an error), 3 with --strict when there is a warning or the scan is incomplete. With --export-mujoco and no port found the twin is still written and the exit code is 0.
- The viewer opens a window and blocks. Without a display, load the file with mujoco.MjModel.from_xml_path("scene.xml") and step it; ctrl = joint goal in rad.
- No arm connected: skip the plug-in step. The export needs no --demo; use --demo only to show example output.
- A freshly powered arm reads Acceleration 0 on every joint until LeRobot has connected once. That is not a fault.
- Do not write servo registers to make the check pass unless the owner asks for it.
- Accuracy, do not overstate it: one arm, bare, one session per gain, not yet replicated. Tip error at rest 6.4 mm at P 16 (180 holds, 8 Nov 2026) and 3.7 mm at P 32 (164 holds, 10 Nov 2026), against 15.2 and 7.7 mm for stock Menagerie.
- Licence: Apache-2.0, the tool and the exported twin folder. Free for any use, companies included; keep the LICENSE and NOTICE files with copies.

Coming soon

This is the first step. The twin above is the same for every arm; next is one fitted to yours.

Per-arm calibration

A twin of your own arm, fitted from a short recording session, used to make the real arm more precise.

Early result, our arm
MeasureResultStatus
Tip error with 200 g at the jaw5.1× lowerNot yet replicated
Measured by camera at a tag on the gripper. One arm, one session, 9 Nov 2026.

Payload and force

Payload and contact force from the servos’ own load readings, with no added sensor.