Skip to content

fix(v1): a Feetech arm's calibration is the file Robot(id=) names, recorded as LeRobot records it; inf/nan hz refused; GLFW renders on macOS - #4803

Open
rmncardoso wants to merge 10 commits into
strands-labs:devfrom
rmncardoso:fix/v1-after-4789
Open

rmncardoso wants to merge 10 commits into
strands-labs:devfrom
rmncardoso:fix/v1-after-4789

Conversation

@rmncardoso

Copy link
Copy Markdown
Collaborator

Fix to parts 2, 3, 5 and 5.5 of #4749 · roadmap #3818

In one paragraph

On macOS, MUJOCO_GL=glfw renders on #4789's strands-gl ended the process (SIGTRAP; GLFW windows need the main thread); now they stay on the caller's thread. Robot(id=) never reached the Feetech driver; now it names the LeRobot file; an id without one loads none, warning beside other files. calibrate() saved where the next connect does not look; now it saves over the file it loaded, but with save=True (the default) records nothing over a guess (no id, the only file there, maybe another arm's). As in LeRobot, it asks before torque goes off, zeroes offsets after the middle pose, gives wrist_roll the full turn, and refuses a sweep leaving a joint without a range; the refusal puts the servos' offsets back. At hz=inf or nan, run() and subscribe("state") ran unpaced; FeetechDriver.write() turned torque on before refusing a NaN. Each now refuses first.

Measured

Fake Feetech bus; dev cc15d9a.

gate dev this PR
Robot(id="my_arm") beside another arm's file: shoulder_pan 1.0 rad; next connect after calibrate(), lower limit (e_calibration.py) goal 2700; encoder 1325 2581; 2148
Robot(id="left") beside only LeRobot's tutorial file: shoulder_pan's lower limit (e_calibration.py) loads it: 1024 loads none, warns: 796, 228 counts past the file's stop
arm B, no id, calibrate() on arm A's file (e_calibration.py) records refused, nothing sent
calibrate(), a file loaded (e_calibration.py) torque off, 6 offset writes, unasked asks; nothing written
wrist_roll still; turned (e_calibration.py) 2047..2047; 1847..2247 0..4095
still gripper: read(); shoulder_pan's upper limit (stop 3072), same session; next connect (e_calibration.py) division by zero; 2548; 3373 ValueError, nothing saved, offsets back: finite; 3072; 3072
no sweep (e_no_sweep.py) goal 4096 ValueError, offsets back
run(hz=inf); subscribe("state", hz=inf); NaN to FeetechDriver.write() (e_rates_write.py) 51 frames in 0.007 s; 5382 reads in 0.2 s; torque on ValueError, nothing sent

Not in this PR

GLFW off the main thread still traps. A Ctrl-C after the middle pose or a failed save leaves new offsets under the old file, 301 counts off at the next connect, which, unlike LeRobot's, never compares them (e_sweep.py, k_failed_save.py). wrist_roll's 0..4095, as in LeRobot's files, maps onto the description's ±2.79 rad: the real wrist turns 65° where the twin shows 57°, and 180° at its limit (k_wrist_roll_map.py, k_wrist_twin.py). Simulation.run(hz=) checks no rate.

Questions

Should a fresh calibrate() save to so_follower/<id>.json, and Enter write the kept file to the servos, as LeRobot 0.6 does?

Evidence

macOS 26.7.1, Python 3.12.10, MuJoCo 3.16.0, LeRobot at the pinned 53feaef3. "dev" is cc15d9a's
strands_robots/ and tests/ from git archive, run from that root; each script runs from the repo root unless
it takes the root as an argument. HF_LEROBOT_CALIBRATION always points at a temp dir. The machine was shared,
so timings are only orders of magnitude.

GLFW. e_gl.sh, the render in a child process per MUJOCO_GL (133 is 128 + SIGTRAP):

#!/bin/zsh
# macOS, from the repo root: tests/test_render_thread.py's _PROBE in a child process per MUJOCO_GL value
# ('' = unset: CGL).
PROBE='
from strands_robots import Robot, Simulation
sim = Simulation()
sim.camera("cam", size=(64, 48), attach=Robot("so101", sim=sim))
try:
    print(sim.render("cam").shape)
except RuntimeError as e:
    print(str(e).split(":")[0])
sim.close()
'
for gl in glfw '' disable; do
  out=$(MUJOCO_GL=$gl python -c "$PROBE" 2>/dev/null); rc=$?
  echo "MUJOCO_GL=${gl:-unset}: exit $rc, printed '${out}'"
done
dev cc15d9a7
MUJOCO_GL=glfw: exit 133, printed ''
MUJOCO_GL=unset: exit 0, printed '(48, 64, 3)'
MUJOCO_GL=disable: exit 0, printed 'rendering unavailable'
this PR
MUJOCO_GL=glfw: exit 0, printed '(48, 64, 3)'
MUJOCO_GL=unset: exit 0, printed '(48, 64, 3)'
MUJOCO_GL=disable: exit 0, printed 'rendering unavailable'

The other callers of the GL thread, python run_child.py glfw python gl_child.py <root> <mode>, which starts the
child with PYTHONPATH unset and a 60 s cap; the table shows the child's returncode from Python's subprocess
(-5: SIGTRAP):

mode dev cc15d9a this PR 269d54c, before #4789
main: a main-thread render -5 0: (48, 64, 3), GLContext from mujoco.glfw 0
task: that, then a background start() -5 0: the run done in 3 steps 0
first-off-main: a first render on a worker thread -5 -5 -5
scene-off-main: a main-thread render, then a worker adds a robot -5 -5 -5

With MUJOCO_GL unset (CGL), scene-off-main returns 0 on all three, its second main-thread render (48, 64, 3).

import sys, threading
sys.path.insert(0, sys.argv[1])
mode = sys.argv[2]
from strands_robots import Robot, Simulation
sim = Simulation()
arm = Robot("so101", sim=sim)
sim.camera("cam", size=(64, 48), attach=arm)
try:
    if mode in ("main", "task", "scene-off-main"):
        img = sim.render("cam")
        import mujoco.gl_context as g
        print(f"main-thread render {img.shape}; GLContext from {getattr(getattr(g, 'GLContext', None), '__module__', None)}", flush=True)
    if mode == "task":
        task = arm.start(lambda o: o.joints.positions, steps=3)
        task._thread.join(20)
        print(f"background run: {task.status}; result {task.result and task.result.steps} steps", flush=True)
    if mode == "first-off-main":
        out = []
        t = threading.Thread(target=lambda: out.append(sim.render("cam").shape), name="worker")
        t.start(); t.join(20)
        print(f"worker-thread render: {out}", flush=True)
    if mode == "scene-off-main":  # the scene rebuild frees the main thread's renderer on the worker
        t = threading.Thread(target=lambda: Robot("so101", sim=sim, position=[0.5, 0, 0]), name="worker")
        t.start(); t.join(20)
        print(f"worker added a robot; main-thread render again {sim.render('cam').shape}", flush=True)
except Exception as e:
    print(f"{type(e).__name__}: {str(e)[:160]}", flush=True)
sim.close()
print("closed", flush=True)

Where scene-off-main traps on this PR: backend.load() (backend.py:92) clears the renderers through
on_gl_thread, which runs inline on the worker that added the robot, and Renderer.__del__ reaches
glfw.destroy_window there. MUJOCO_GL=glfw python gl_trap_stack.py <root>, paths shortened:

# The scene-off-main child with a SIGTRAP traceback: argv[1] = root. Run under MUJOCO_GL=glfw.
import faulthandler, signal, sys, threading
faulthandler.register(signal.SIGTRAP, all_threads=True, chain=True)
sys.path.insert(0, sys.argv[1])
from strands_robots import Robot, Simulation
sim = Simulation()
arm = Robot("so101", sim=sim)
sim.camera("cam", size=(64, 48), attach=arm)
print("main render", sim.render("cam").shape, flush=True)
t = threading.Thread(target=lambda: Robot("so101", sim=sim, position=[0.5, 0, 0]), name="worker")
t.start(); t.join(20)
print("worker added a robot", flush=True)
main render (48, 64, 3)
Current thread 0x000000016e463000 (most recent call first):
  File "<site-packages>/glfw/__init__.py", line 1281 in destroy_window
  File "<site-packages>/mujoco/glfw/__init__.py", line 37 in free
  File "<site-packages>/mujoco/rendering/classic/renderer.py", line 335 in close
  File "<site-packages>/mujoco/rendering/classic/renderer.py", line 349 in __del__
  File "<root>/strands_robots/_gl.py", line 64 in on_gl_thread
  File "<root>/strands_robots/simulation/backends/mujoco/backend.py", line 92 in load
  File "<root>/strands_robots/simulation/_sceneops.py", line 71 in _reload
  File "<root>/strands_robots/simulation/_sceneops.py", line 79 in _grow
  File "<root>/strands_robots/simulation/_sceneops.py", line 65 in add
  File "<root>/strands_robots/robot/robot.py", line 89 in __init__
  File "<scripts>/gl_trap_stack.py", line 10 in <lambda>
  File "<python>/threading.py", line 1012 in run
  File "<python>/threading.py", line 1075 in _bootstrap_inner
  File "<python>/threading.py", line 1032 in _bootstrap

Thread 0x00000001edbba1c0 (most recent call first):
  File "<python>/threading.py", line 1169 in _wait_for_tstate_lock
  File "<python>/threading.py", line 1153 in join
  File "<scripts>/gl_trap_stack.py", line 11 in <module>
exit 133

No GLFW child is in the suite: when this Mac's session went idle on 2026-10-18 (pmset -g assertions:
UserIsActive 0), a plain glfw.init() hung and such a test timed out on every commit. The routing test pins the
decision on every platform, and a MUJOCO_GL=disable child pins "rendering unavailable".

Calibration. e_calibration.py:

# From the repo root: python e_calibration.py. The suite's FakeSerial; HF_LEROBOT_CALIBRATION is a temp dir.
import json, os, shutil, sys, tempfile, warnings
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
import numpy as np
from test_drivers import CAL, FakeSerial
from strands_robots import Description, Robot
from strands_robots.robot.drivers import feetech as ft
from strands_robots.robot.drivers.feetech import driver as ftd

OTHER = {k: dict(v, range_min=1124, range_max=3172) for k, v in CAL.items()}  # another arm's file
AT_120 = {k: dict(v, homing_offset=120) for k, v in CAL.items()}
LO, UP = Description.load("so101").limits["shoulder_pan"]  # shoulder_pan's lower and upper limits, rad
LOWER = np.array([LO, 0, 0, 0, 0, 0])

def disk(files):  # {"so_follower/my_arm.json": calibration}, under robots/ as LeRobot files them
    shutil.rmtree(os.path.join(root, "robots"), ignore_errors=True)
    for rel, cal in files.items():
        p = os.path.join(root, "robots", rel)
        os.makedirs(os.path.dirname(p), exist_ok=True)
        json.dump(cal, open(p, "w"))

def arm(s, **kw):
    return ft.FeetechDriver(Description.load("so101"), port="/dev/fake", bus=ft.FeetechBus("/dev/fake", serial=s), **kw)

def goal(s):
    return int.from_bytes(s.regs[1][ft.REG_GOAL_POSITION : ft.REG_GOAL_POSITION + 2], "little")

def lower(d, s):  # shoulder_pan to its lower limit: the goal sent and where the encoder ends
    d.write(LOWER)
    return f"lower limit: goal {goal(s)}, encoder {s.actual[1]}"

def upper(d, s):  # shoulder_pan to its upper limit: the goal sent and where the encoder ends
    d.write(np.array([UP, 0, 0, 0, 0, 0]))
    return f"upper limit: goal {goal(s)}, encoder {s.actual[1]}"

def short(p):
    return p and os.path.relpath(p, os.path.join(root, "robots"))

def files():
    return sorted(short(os.path.join(d, f)) for d, _, fs in os.walk(os.path.join(root, "robots")) for f in fs)

def writes(s):
    w = [f for f in s.frames if f[4] in (ft.WRITE, ft.SYNC_WRITE)]
    return f"torque frames {[f[8] for f in w if f[5] == ft.REG_TORQUE_ENABLE]}, homing writes {sum(f[5] == ft.REG_HOMING_OFFSET for f in w)}"

def read(d):
    try:
        return f"finite: {bool(np.isfinite(d.read().positions).all())}"
    except Exception as e:  # noqa: BLE001
        return f"{type(e).__name__}: {e}"

class Sweep(FakeSerial):  # the operator's sweep: +-200 around the middle, one end per SYNC_READ; `still` servos stay
    moving = flip = False
    still = ()

    def write(self, data):
        if self.moving and data[4] == ft.SYNC_READ:
            self.flip = not self.flip
            self.actual.update({i: 2348 + (200 if self.flip else -200) for i in self.actual if i not in self.still})
        super().write(data)

def operator(s, answers, seen):  # answers in order ("^C" raises); the MIDDLE pose is encoder 2348
    it = iter(answers)

    def prompt(msg):
        seen.append(writes(s))
        a = next(it, "")
        if a == "^C":
            raise KeyboardInterrupt
        if "MIDDLE" in msg:
            s.actual.update(dict.fromkeys(s.actual, 2348))
        s.moving = s.moving or "full range" in msg
        return a

    return prompt

def calibrate(d, s, answers):  # "c" answers this PR's question; on dev, which asks none, the middle-pose prompt eats it
    n0 = len(s.frames)
    try:
        cal = d.calibrate(interactive=True, prompt=operator(s, answers, []), sweep_seconds=0.05)
        return f"homing {cal.joints['shoulder_pan'].homing_offset}, saved to {short(d.calibration_source)}"
    except (RuntimeError, ValueError) as e:
        sent = sum(f[4] in (ft.WRITE, ft.SYNC_WRITE) for f in s.frames[n0:])
        return f"{type(e).__name__}: {str(e).replace(root, '<root>')}; write frames sent {sent}"
    finally:
        s.moving = False

for rid, given, rad in (
    ("my_arm", {"so_follower/my_arm.json": CAL, "so_follower/other_arm.json": OTHER}, 1.0),
    ("my_arm", {"so_follower/other_arm.json": OTHER}, 1.0),
    ("left", {"so_follower/my_awesome_follower_arm.json": CAL}, LO),  # LeRobot's tutorial id; the lower limit
):
    disk(given)
    s = FakeSerial()
    ftd.FeetechBus = lambda port, baud: ft.FeetechBus(port, serial=s)
    with warnings.catch_warnings(record=True) as seen:
        warnings.simplefilter("always")
        real = Robot("so101", port="/dev/fake", id=rid, twin=False)
    real.act({"shoulder_pan": rad})
    said = [str(w.message).replace(root, "<root>") for w in seen]
    print(f"id: {sorted(given)} -> Robot(id={rid!r}) loads {short(real.body.calibration_source)}; shoulder_pan={rad:.2f}: goal {goal(s)}; warnings {said}")
    real.close()

disk({"so_follower/my_arm.json": CAL})
s = Sweep()
d = arm(s, robot_id="my_arm")
print(f"save: session 1, id 'my_arm', loads {short(d.calibration_source)}; {lower(d, s)}")
print(f"save: calibrate(): {calibrate(d, s, ['c'])}; servos hold {s._homing(1)}; files {files()}")
d2 = arm(s, robot_id="my_arm")
print(f"save: session 2 loads {short(d2.calibration_source)} (homing {d2.calibration.joints['shoulder_pan'].homing_offset}); {lower(d2, s)}")

disk({"so_follower/arm_a.json": CAL})  # two arms, one root: only arm A's file is on disk; A's servos hold homing 0
b = Sweep()
rb = arm(b)
print(f"two arms: B, no id, loads {short(rb.calibration_source)}; B's calibrate(): {calibrate(rb, b, ['c'])}; files {files()}")
a = FakeSerial()
for kw in ({}, {"robot_id": "arm_a"}):
    ra = arm(a, **kw)
    print(f"two arms: A, {'id ' + repr(kw['robot_id']) if kw else 'no id'}, loads {short(ra.calibration_source)}; {lower(ra, a)}")
rb2 = arm(b)
print(f"two arms: B again, no id, loads {short(rb2.calibration_source)}; {lower(rb2, b)}")

disk({"so_follower/my_arm.json": CAL})  # one arm, calibrated by LeRobot as "my_arm": the only file there
s = Sweep()
d = arm(s)
print(f"guess: no id, loads {short(d.calibration_source)}; calibrate(): {calibrate(d, s, ['c'])}; servos hold {s._homing(1)}")
d = arm(s, robot_id="my_arm")
print(f"guess: then id 'my_arm', calibrate(): {calibrate(d, s, ['c'])}; files {files()}")
for kw in ({"robot_id": "my_arm"}, {}):
    d2 = arm(s, **kw)
    print(f"guess: next connect, {'id' if kw else 'no id'}, loads {short(d2.calibration_source)}; {lower(d2, s)}")

for answers in ([""], ["^C"], ["c", "^C"]):
    disk({"so_follower/my_arm.json": AT_120})
    s = Sweep()
    for i in s.regs:
        s.regs[i][ft.REG_HOMING_OFFSET : ft.REG_HOMING_OFFSET + 2] = ft.encode_sm(120, 11)
    d, seen = arm(s, robot_id="my_arm"), []
    try:
        d.calibrate(interactive=True, prompt=operator(s, answers, seen), sweep_seconds=0.05)
        how = "returned"
    except KeyboardInterrupt:
        how = "Ctrl-C"
    print(f"ask {answers}: {how} after {len(seen)} prompt(s); before the first answer: {seen[0]}; servos hold {s._homing(1)}, the file 120")

for still, what in (({5}, "wrist_roll still, as LeRobot asks"), (set(), "every joint moves")):
    disk({})
    s = Sweep()
    s.still = still
    d = arm(s, robot_id="my_arm")
    said = calibrate(d, s, [])
    w = d.calibration.joints["wrist_roll"]
    print(f"wrist_roll: sweep, {what}: calibrate(): {said}; wrist_roll range {w.range_min}..{w.range_max}; read(): {read(d)}")

disk({"so_follower/my_arm.json": CAL})  # a sweep in which the gripper (servo 6) never moves; the stops 1024, 3072
s = Sweep()
s.still = {6}
d = arm(s, robot_id="my_arm")
said = calibrate(d, s, ["c"])
g = d.calibration.joints["gripper"]
print(f"still gripper: calibrate(): {said}; the driver's gripper range now {g.range_min}..{g.range_max}; servos hold {s._homing(1)}")
print(f"still gripper: the same session's read(): {read(d)}; {upper(d, s)}")
d2 = arm(s, robot_id="my_arm")
print(f"still gripper: next connect loads {short(d2.calibration_source)}; {upper(d2, s)}")
shutil.rmtree(root)
dev cc15d9a7
id: ['so_follower/my_arm.json', 'so_follower/other_arm.json'] -> Robot(id='my_arm') loads None; shoulder_pan=1.00: goal 2700; warnings []
id: ['so_follower/other_arm.json'] -> Robot(id='my_arm') loads so_follower/other_arm.json; shoulder_pan=1.00: goal 2681; warnings []
id: ['so_follower/my_awesome_follower_arm.json'] -> Robot(id='left') loads so_follower/my_awesome_follower_arm.json; shoulder_pan=-1.92: goal 1024; warnings []
save: session 1, id 'my_arm', loads so_follower/my_arm.json; lower limit: goal 1024, encoder 1024
save: calibrate(): homing 301, saved to so101_follower/so101_follower.json; servos hold 301; files ['so101_follower/so101_follower.json', 'so_follower/my_arm.json']
save: session 2 loads so_follower/my_arm.json (homing 0); lower limit: goal 1024, encoder 1325
two arms: B, no id, loads so_follower/arm_a.json; B's calibrate(): homing 301, saved to so101_follower/so101_follower.json; files ['so101_follower/so101_follower.json', 'so_follower/arm_a.json']
two arms: A, no id, loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1024
two arms: A, id 'arm_a', loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1024
two arms: B again, no id, loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1325
guess: no id, loads so_follower/my_arm.json; calibrate(): homing 301, saved to so101_follower/so101_follower.json; servos hold 301
guess: then id 'my_arm', calibrate(): homing 301, saved to so101_follower/so101_follower.json; files ['so101_follower/so101_follower.json', 'so_follower/my_arm.json']
guess: next connect, id, loads so_follower/my_arm.json; lower limit: goal 1024, encoder 1325
guess: next connect, no id, loads so_follower/my_arm.json; lower limit: goal 1024, encoder 1325
ask ['']: returned after 2 prompt(s); before the first answer: torque frames [0], homing writes 6; servos hold 301, the file 120
ask ['^C']: Ctrl-C after 1 prompt(s); before the first answer: torque frames [0], homing writes 6; servos hold 0, the file 120
ask ['c', '^C']: Ctrl-C after 2 prompt(s); before the first answer: torque frames [0], homing writes 6; servos hold 301, the file 120
wrist_roll: sweep, wrist_roll still, as LeRobot asks: calibrate(): homing 301, saved to so101_follower/so101_follower.json; wrist_roll range 2047..2047; read(): ZeroDivisionError: float division by zero
wrist_roll: sweep, every joint moves: calibrate(): homing 301, saved to so101_follower/so101_follower.json; wrist_roll range 1847..2247; read(): finite: True
still gripper: calibrate(): homing 301, saved to so101_follower/so101_follower.json; the driver's gripper range now 2047..2047; servos hold 301
still gripper: the same session's read(): ZeroDivisionError: float division by zero; upper limit: goal 2247, encoder 2548
still gripper: next connect loads so_follower/my_arm.json; upper limit: goal 3072, encoder 3373
this PR
id: ['so_follower/my_arm.json', 'so_follower/other_arm.json'] -> Robot(id='my_arm') loads so_follower/my_arm.json; shoulder_pan=1.00: goal 2581; warnings []
id: ['so_follower/other_arm.json'] -> Robot(id='my_arm') loads None; shoulder_pan=1.00: goal 2700; warnings ["so101_follower: no calibration file for id 'my_arm', only ['<root>/robots/so_follower/other_arm.json']; joints map raw full-turn counts. Pass calibration=<path>, or the id LeRobot calibrated this arm with"]
id: ['so_follower/my_awesome_follower_arm.json'] -> Robot(id='left') loads None; shoulder_pan=-1.92: goal 796; warnings ["so101_follower: no calibration file for id 'left', only ['<root>/robots/so_follower/my_awesome_follower_arm.json']; joints map raw full-turn counts. Pass calibration=<path>, or the id LeRobot calibrated this arm with"]
save: session 1, id 'my_arm', loads so_follower/my_arm.json; lower limit: goal 1024, encoder 1024
save: calibrate(): homing 301, saved to so_follower/my_arm.json; servos hold 301; files ['so_follower/my_arm.json']
save: session 2 loads so_follower/my_arm.json (homing 301); lower limit: goal 1847, encoder 2148
two arms: B, no id, loads so_follower/arm_a.json; B's calibrate(): RuntimeError: with no id, <root>/robots/so_follower/arm_a.json may be another arm's: pass id= or calibration=; write frames sent 0; files ['so_follower/arm_a.json']
two arms: A, no id, loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1024
two arms: A, id 'arm_a', loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1024
two arms: B again, no id, loads so_follower/arm_a.json; lower limit: goal 1024, encoder 1024
guess: no id, loads so_follower/my_arm.json; calibrate(): RuntimeError: with no id, <root>/robots/so_follower/my_arm.json may be another arm's: pass id= or calibration=; write frames sent 0; servos hold 0
guess: then id 'my_arm', calibrate(): homing 301, saved to so_follower/my_arm.json; files ['so_follower/my_arm.json']
guess: next connect, id, loads so_follower/my_arm.json; lower limit: goal 1847, encoder 2148
guess: next connect, no id, loads so_follower/my_arm.json; lower limit: goal 1847, encoder 2148
ask ['']: returned after 1 prompt(s); before the first answer: torque frames [], homing writes 0; servos hold 120, the file 120
ask ['^C']: Ctrl-C after 1 prompt(s); before the first answer: torque frames [], homing writes 0; servos hold 120, the file 120
ask ['c', '^C']: Ctrl-C after 2 prompt(s); before the first answer: torque frames [], homing writes 0; servos hold 120, the file 120
wrist_roll: sweep, wrist_roll still, as LeRobot asks: calibrate(): homing 301, saved to so101_follower/my_arm.json; wrist_roll range 0..4095; read(): finite: True
wrist_roll: sweep, every joint moves: calibrate(): homing 301, saved to so101_follower/my_arm.json; wrist_roll range 0..4095; read(): finite: True
still gripper: calibrate(): ValueError: so101: the sweep recorded no range for ['gripper']: nothing saved, and the servos have their homing offsets back. Run calibrate() again and move each of them through its full range; write frames sent 55; the driver's gripper range now 1024..3072; servos hold 0
still gripper: the same session's read(): finite: True; upper limit: goal 3072, encoder 3072
still gripper: next connect loads so_follower/my_arm.json; upper limit: goal 3072, encoder 3072

This PR's question reads so101 has a calibration (<root>/robots/so_follower/my_arm.json). Enter keeps it, c re-records (torque goes off). In the still gripper lines my_arm.json's stops are encoder 1024 and 3072:
dev's next connect maps that file onto the new offsets (3373); this PR's refusal puts the old offsets back
(servos hold 0), and both sessions send the upper limit to 3072.

In the Robot(id='left') lines LeRobot's tutorial file, my_awesome_follower_arm.json, is the only one there,
with shoulder_pan's stops at 1024 and 3072 (homing 0). Dev never passes the id, so it loads that file and sends
the lower limit, -1.92 rad, as goal 1024. This PR's id names left.json, which is not there: it loads none, warns,
and maps the full turn (2048 is 0 rad), so the same limit goes out as goal 796, 228 counts (20°) past that file's
stop. The warning names the file and asks for calibration=<path> or the id LeRobot calibrated the arm with. The
fake bus enforces no position limit: what a servo holding LeRobot's Min_Position_Limit (its write_calibration(),
feetech.py:268-273) does with goal 796 was not measured.

A sweep with no reading, e_no_sweep.py:

# From the repo root: python e_no_sweep.py. calibrate(sweep_seconds=0) on the suite's FakeSerial (no file loaded,
# so no question on either tree): what it records, the homing offset servo 1 then holds (0 before), and the goals
# sent at shoulder_pan's two limits afterwards.
import os, shutil, sys, tempfile
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
import numpy as np
from test_drivers import FakeSerial
from strands_robots import Description
from strands_robots.robot.drivers import feetech as ft

s = FakeSerial()
d = ft.FeetechDriver(Description.load("so101"), port="/dev/fake", bus=ft.FeetechBus("/dev/fake", serial=s), robot_id="my_arm")
try:
    cal = d.calibrate(interactive=True, prompt=lambda msg: "", sweep_seconds=0)
    j = cal.joints["shoulder_pan"]
    print(f"calibrate(sweep_seconds=0): shoulder_pan range_min {j.range_min}, range_max {j.range_max}; servos hold {s._homing(1)}")
    for k, rad in enumerate(d.description.limits["shoulder_pan"]):
        d.write(np.array([rad, 0, 0, 0, 0, 0]))
        print(f"  shoulder_pan to its {('lower', 'upper')[k]} limit: goal {int.from_bytes(s.regs[1][ft.REG_GOAL_POSITION : ft.REG_GOAL_POSITION + 2], 'little')}")
except ValueError as e:
    print(f"calibrate(sweep_seconds=0): ValueError: {e}; servos hold {s._homing(1)}")
shutil.rmtree(root)
dev cc15d9a7
calibrate(sweep_seconds=0): shoulder_pan range_min 4096, range_max 0; servos hold 1
  shoulder_pan to its lower limit: goal 4096
  shoulder_pan to its upper limit: goal 0
this PR
calibrate(sweep_seconds=0): ValueError: so101: the sweep recorded no range for ['shoulder_pan', 'shoulder_lift', 'elbow_flex', 'wrist_flex', 'gripper']: nothing saved, and the servos have their homing offsets back. Run calibrate() again and move each of them through its full range; servos hold 0

A Ctrl-C after the middle pose, at the sweep's prompt and inside the sweep, e_sweep.py (python e_sweep.py on
dev, which asks no question; python e_sweep.py c here):

# From the repo root: python e_sweep.py [c] ("c" answers this PR's question; dev asks none). A Ctrl-C at the sweep
# prompt, then one on the sweep's 5th bus read, each after the middle pose. FakeSerial; a temp HF_LEROBOT_CALIBRATION.
import json, os, shutil, sys, tempfile
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
import numpy as np
from test_drivers import CAL, FakeSerial
from strands_robots import Description
from strands_robots.robot.drivers import feetech as ft

path = os.path.join(root, "robots", "so_follower", "my_arm.json")
os.makedirs(os.path.dirname(path))

class Sweep(FakeSerial):  # Ctrl-C on the sweep's n-th bus read
    at = reads = 0

    def write(self, data):
        if self.at and data[4] == ft.SYNC_READ:
            self.reads += 1
            if self.reads == self.at:
                raise KeyboardInterrupt
        super().write(data)

def arm(s):
    return ft.FeetechDriver(Description.load("so101"), port="/dev/fake", bus=ft.FeetechBus("/dev/fake", serial=s), robot_id="my_arm")

for where in ("the sweep prompt", "the sweep's 5th bus read"):
    json.dump(CAL, open(path, "w"))
    s, answers = Sweep(), iter(sys.argv[1:])

    def prompt(msg):
        if "MIDDLE" in msg:
            s.actual.update(dict.fromkeys(s.actual, 2348))  # the operator's middle pose
            return ""
        if "full range" in msg:
            if where == "the sweep prompt":
                raise KeyboardInterrupt
            s.at = 5
            return ""
        return next(answers)

    try:
        arm(s).calibrate(interactive=True, prompt=prompt, sweep_seconds=5)
    except KeyboardInterrupt:
        pass
    s.at = 0
    d = arm(s)  # the next connect
    d.write(np.array([d.description.limits["shoulder_pan"][0], 0, 0, 0, 0, 0]))
    goal = int.from_bytes(s.regs[1][ft.REG_GOAL_POSITION : ft.REG_GOAL_POSITION + 2], "little")
    print(f"Ctrl-C at {where}: servos hold homing {s._homing(1)}, the file {json.load(open(path))['shoulder_pan']['homing_offset']}; "
          f"next connect: lower limit goal {goal}, encoder {goal + s._homing(1)}")
shutil.rmtree(root)
dev cc15d9a7
Ctrl-C at the sweep prompt: servos hold homing 301, the file 0; next connect: lower limit goal 1024, encoder 1325
Ctrl-C at the sweep's 5th bus read: servos hold homing 301, the file 0; next connect: lower limit goal 1024, encoder 1325
this PR
Ctrl-C at the sweep prompt: servos hold homing 301, the file 0; next connect: lower limit goal 1024, encoder 1325
Ctrl-C at the sweep's 5th bus read: servos hold homing 301, the file 0; next connect: lower limit goal 1024, encoder 1325

A save that fails after a full sweep (my_arm.json made read-only), python k_failed_save.py c '' '':

# From a root holding strands_robots/ and tests/ (with SweepSerial): python k_failed_save.py <answers...>.
# so_follower/my_arm.json (homing 0, stops 1024..3072) loaded for id "my_arm" and made read-only; a full sweep,
# then the save fails. After it: the servos' offset, and shoulder_pan's upper limit in the same session and at
# the next connect.
import json, os, shutil, stat, sys, tempfile
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
import numpy as np
from test_drivers import CAL, SweepSerial
from strands_robots import Description
from strands_robots.robot.drivers import feetech as ft
p = os.path.join(root, "robots", "so_follower", "my_arm.json")
os.makedirs(os.path.dirname(p)); json.dump(CAL, open(p, "w")); os.chmod(p, stat.S_IRUSR)
UP = Description.load("so101").limits["shoulder_pan"][1]
arm = lambda s: ft.FeetechDriver(Description.load("so101"), port="/dev/fake", bus=ft.FeetechBus("/dev/fake", serial=s), robot_id="my_arm")
def upper(d, s):
    d.write(np.array([UP, 0, 0, 0, 0, 0]))
    return f"goal {int.from_bytes(s.regs[1][ft.REG_GOAL_POSITION : ft.REG_GOAL_POSITION + 2], 'little')} -> encoder {s.actual[1]}"
s = SweepSerial()
d = arm(s)
s.actual.update(dict.fromkeys(s.actual, 2348))
it = iter(sys.argv[1:])
try:
    d.calibrate(interactive=True, prompt=lambda msg: next(it), sweep_seconds=0.1)
    print(f"calibrate(): returned; servos hold homing {s._homing(1)}")
except OSError as e:
    print(f"calibrate(): {type(e).__name__}; servos hold homing {s._homing(1)}; the file's homing {json.load(open(p))['shoulder_pan']['homing_offset']}")
s.still = set(s.actual)
print(f"same session: shoulder_pan upper limit {upper(d, s)}")
print(f"next connect: shoulder_pan upper limit {upper(arm(s), s)} (my_arm.json's stop: 3072)")
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR); shutil.rmtree(root)
this PR
calibrate(): PermissionError; servos hold homing 301; the file's homing 0
same session: shoulder_pan upper limit goal 2247 -> encoder 2548
next connect: shoulder_pan upper limit goal 3072 -> encoder 3373 (my_arm.json's stop: 3072)

LeRobot at the pinned 53feaef3, a code reading of robots/so_follower/so_follower.py, motors/feetech/feetech.py
and motors/motors_bus.py: calibrate() asks first when a calibration exists (so_follower.py:139-147), and
Enter writes the kept one to the servos (:146, write_calibration(): Homing_Offset, Min_Position_Limit and
Max_Position_Limit, feetech.py:268-273); homing is set only after the middle-pose Enter, for every motor
(:156-157, set_half_turn_homings(), which first calls reset_calibration(), motors_bus.py:791; that empties
the bus calibration, :773, so after a refused sweep every normalized read or write raises "has no calibration
registered", :855-856 and :884-885); wrist_roll is left out of the sweep and recorded as 0..4095
(so_follower.py:160-168);
record_ranges_of_motion() raises ValueError("Some motors have the same min and max values: ...") for the
motors it sweeps (motors_bus.py:848-850); and connect() runs calibrate() again (so_follower.py:122-126)
when is_calibrated is false, that is when the servos' offsets or ranges differ from the file's, or no file
loaded (feetech.py:228-245).

Acceptance check 9 on the fake bus, zsh acceptance_calibrate.sh <root> <scratch>, which writes a LeRobot-format
robots/so_follower/my_arm.json and runs the calibrate test with this plugin beside it:

#!/bin/zsh
# Acceptance check 9's calibrate test on the fake bus, with a LeRobot-style so_follower/my_arm.json on disk.
# $1 = a tree holding strands_robots/, tests/test_drivers.py and tests_integ/; $2 = scratch dir (removed after)
set -e
root=$1; scratch=$2
rm -rf $scratch; mkdir -p $scratch/hf/robots/so_follower
python3 -I -c "
import json
names = ('shoulder_pan', 'shoulder_lift', 'elbow_flex', 'wrist_flex', 'wrist_roll', 'gripper')
cal = {n: {'id': i + 1, 'drive_mode': int(n == 'wrist_roll'), 'homing_offset': 0, 'range_min': 1024, 'range_max': 3072} for i, n in enumerate(names)}
json.dump(cal, open('$scratch/hf/robots/so_follower/my_arm.json', 'w'), indent=4)"
touch $scratch/port
cd $root
HF_LEROBOT_CALIBRATION=$scratch/hf STRANDS_SO101_PORT=$scratch/port PYTHONPATH=$root:$root/tests:${0:A:h} \
  nice -n 10 pytest -q -p no:cacheprovider --timeout=300 -p fakebus_plugin -rs tests_integ/acceptance/test_feetech_so101.py -k calibrate 2>&1 | tail -4
rm -rf $scratch
"""pytest plugin: every FeetechBus the driver opens is the suite's FakeSerial (no serial port is opened).

usage: PYTHONPATH=<tree>:<tree>/tests:<this dir> pytest -p fakebus_plugin tests_integ/acceptance
STRANDS_SO101_PORT only has to name an existing plain file (the module's skipif).
"""

import pytest


@pytest.hookimpl(tryfirst=True)
def pytest_configure(config):
    import strands_robots.robot.drivers.feetech.driver as fd
    from test_drivers import FakeSerial

    from strands_robots.robot.drivers import feetech as ft

    fd.FeetechBus = lambda port, *, baud=1_000_000: ft.FeetechBus(port, serial=FakeSerial())
dev cc15d9a7
s                                                                        [100%]
=========================== short test summary info ============================
SKIPPED [1] tests_integ/acceptance/test_feetech_so101.py:108: no LeRobot calibration at <scratch>/hf/robots/so101_follower/so101_follower.json: run lerobot-calibrate for this arm first
1 skipped, 4 deselected in 0.72s
this PR
.                                                                        [100%]
1 passed, 4 deselected in 1.21s

wrist_roll's full turn. LeRobot records wrist_roll as 0..4095 (so_follower.py:167-168), and so does this
PR's calibrate(); the driver maps that range onto the description's limits, ±2.7925 rad. After this PR's
calibrate() with the middle pose at encoder 2348 (homing 301), python k_wrist_roll_map.py:

# From a root holding strands_robots/ and tests/ (with SweepSerial): python k_wrist_roll_map.py. A calibrate() with
# answers c, '', '' over so_follower/my_arm.json (id "my_arm"), middle pose at encoder 2348; then wrist_roll (servo 5)
# to a few angles: the goal sent and the encoder's angle from the middle pose on the suite's fake bus.
import json, math, os, shutil, sys, tempfile
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
import numpy as np
from test_drivers import CAL, SweepSerial
from strands_robots import Description
from strands_robots.robot.drivers import feetech as ft
p = os.path.join(root, "robots", "so_follower", "my_arm.json")
os.makedirs(os.path.dirname(p)); json.dump(CAL, open(p, "w"))
s = SweepSerial(); d = ft.FeetechDriver(Description.load("so101"), port="/dev/fake", bus=ft.FeetechBus("/dev/fake", serial=s), robot_id="my_arm")
s.actual.update(dict.fromkeys(s.actual, 2348)); it = iter(["c", "", ""])
cal = d.calibrate(interactive=True, prompt=lambda msg: next(it), sweep_seconds=0.1)
s.still = set(s.actual)
print(f"wrist_roll range {cal.joints['wrist_roll'].range_min}..{cal.joints['wrist_roll'].range_max}, homing {cal.joints['wrist_roll'].homing_offset}")
for r in (1.0, 2.0, 2.4, 2.7925):
    d.write(np.array([0, 0, 0, 0, r, 0]))
    g = int.from_bytes(s.regs[5][ft.REG_GOAL_POSITION:ft.REG_GOAL_POSITION + 2], "little")
    print(f"wrist_roll {r:.4f} rad ({math.degrees(r):.0f} deg): goal {g} ({(g - 2047) * 360 / 4096:+.0f} deg from 2047), encoder {s.actual[5]} ({(s.actual[5] - 2348) * 360 / 4096:+.0f} deg from the middle pose)")
shutil.rmtree(root)
this PR
wrist_roll range 0..4095, homing 301
wrist_roll 1.0000 rad (57 deg): goal 2781 (+65 deg from 2047), encoder 3082 (+65 deg from the middle pose)
wrist_roll 2.0000 rad (115 deg): goal 3514 (+129 deg from 2047), encoder 3815 (+129 deg from the middle pose)
wrist_roll 2.4000 rad (138 deg): goal 3807 (+155 deg from 2047), encoder 12 (-205 deg from the middle pose)
wrist_roll 2.7925 rad (160 deg): goal 4095 (+180 deg from 2047), encoder 300 (-180 deg from the middle pose)

Goal 2781 is 734 counts, 64.5°, from the middle (2047), where the description's angle is 57.3°; at the limit the
goal is 4095, 180° from the middle, where the description says 160°. At 2.4 rad the fake bus, which models
Present = Actual - Homing mod 4096, puts the encoder at 12, -205° from the middle pose, the other way around; what a
real STS3215 does with a goal past 4095 minus its offset was not measured. A file LeRobot made maps the same way on
dev and on this PR, and the twin shows the description's angle: python k_wrist_twin.py, a real
Robot(id="my_arm") with its twin over a LeRobot-shaped so_follower/my_arm.json:

# From a root holding strands_robots/ and tests/: python k_wrist_twin.py. A LeRobot-made so_follower/my_arm.json
# (drive_mode 0, homing 0, wrist_roll 0..4095 as LeRobot's so_follower.py:167-168 records it, the other joints
# 1024..3072) is the only file there; a real Robot("so101", id="my_arm") on the suite's FakeSerial, with its twin.
# wrist_roll to 1.0 rad and to its upper limit: the goal sent, the servo's angle from the middle (count 2047), what
# the real robot reports and where its twin's wrist_roll is placed.
import json, math, os, shutil, sys, tempfile
sys.path[:0] = [".", "tests"]
os.environ["HF_LEROBOT_CALIBRATION"] = root = tempfile.mkdtemp()
from test_drivers import CAL, FakeSerial
from strands_robots import Description, Robot
from strands_robots.robot.drivers import feetech as ft
from strands_robots.robot.drivers.feetech import driver as ftd

cal = {k: dict(v, drive_mode=0) for k, v in CAL.items()}
cal["wrist_roll"].update(range_min=0, range_max=4095)
p = os.path.join(root, "robots", "so_follower", "my_arm.json")
os.makedirs(os.path.dirname(p))
json.dump(cal, open(p, "w"))
s = FakeSerial()
ftd.FeetechBus = lambda port, baud: ft.FeetechBus(port, serial=s)
real = Robot("so101", port="/dev/fake", id="my_arm")
k = real.body.names.index("wrist_roll")
up = Description.load("so101").limits["wrist_roll"][1]
src = os.path.relpath(real.body.calibration_source, os.path.join(root, "robots"))
print(f"loads {src}; wrist_roll range {real.body.calibration.joints['wrist_roll'].range_min}..{real.body.calibration.joints['wrist_roll'].range_max}; limits +-{up:.4f} rad")
for r in (1.0, up):
    obs = real.act({"wrist_roll": r})
    goal = int.from_bytes(s.regs[k + 1][ft.REG_GOAL_POSITION : ft.REG_GOAL_POSITION + 2], "little")
    tw = real.twin.observe(images=False).joints
    t = float(tw.positions[tw.names.index("wrist_roll")])
    print(
        f"wrist_roll {r:.4f} rad ({math.degrees(r):.1f} deg): goal {goal}, servo {(goal - 2047) * 360 / 4096:+.1f} deg "
        f"from the middle; real reports {math.degrees(obs.joints.positions[k]):.1f} deg; twin placed at {math.degrees(t):.1f} deg"
    )
real.close()
shutil.rmtree(root)
dev cc15d9a7
loads so_follower/my_arm.json; wrist_roll range 0..4095; limits +-2.7925 rad
wrist_roll 1.0000 rad (57.3 deg): goal 2781, servo +64.5 deg from the middle; real reports 57.3 deg; twin placed at 57.3 deg
wrist_roll 2.7925 rad (160.0 deg): goal 4095, servo +180.0 deg from the middle; real reports 160.0 deg; twin placed at 160.0 deg
this PR
loads so_follower/my_arm.json; wrist_roll range 0..4095; limits +-2.7925 rad
wrist_roll 1.0000 rad (57.3 deg): goal 2781, servo +64.5 deg from the middle; real reports 57.3 deg; twin placed at 57.3 deg
wrist_roll 2.7925 rad (160.0 deg): goal 4095, servo +180.0 deg from the middle; real reports 160.0 deg; twin placed at 160.0 deg

Dev's own calibrate() recorded the swept range instead (2047..2047 or 1847..2247 in the wrist_roll lines
above).

Rates and the driver's write. e_rates_write.py:

# From the repo root: python e_rates_write.py. A hardware Robot on the suite's FakeSerial.
import math, sys, time
sys.path[:0] = [".", "tests"]
import numpy as np
from test_drivers import make_driver, writes
from test_operator_gate import goal_writes
from strands_robots import Robot
from strands_robots.robot.drivers import feetech as ft

class Loads:  # attach() stands for loading a checkpoint
    attached = False
    def attach(self, robot): self.attached = True
    def act(self, obs, *, instruction=None): return {"shoulder_pan": 0.1}
    def reset(self): pass

for hz in (50.0, math.inf, math.nan, 1e6):
    d, s = make_driver(); real = Robot("so101", driver=d, twin=False); pol = Loads(); t = time.perf_counter()
    try: out = f"{real.run(pol, hz=hz, steps=50).steps} steps"
    except ValueError as e: out = f"ValueError: {e}"
    print(f"run(hz={hz}, steps=50): {out}; attached {pol.attached}; goal/torque frames {len(goal_writes(s))} in {time.perf_counter() - t:.3f} s")
    real.close()
for hz in (50.0, math.inf, math.nan, 1e6):
    d, s = make_driver(); real = Robot("so101", driver=d, twin=False); n0 = len(s.frames)
    try: real.subscribe("state", lambda p: None, hz=hz); out = "accepted"
    except ValueError as e: out = f"ValueError: {e}"
    time.sleep(0.2)
    print(f"subscribe('state', hz={hz}): {out}; bus reads in 0.2 s: {sum(f[4] == ft.SYNC_READ for f in s.frames[n0:])}")
    real.close()
d, s = make_driver()
try: d.write(np.array([math.nan, 0, 0, 0, 0, 0]))
except ValueError as e: print(f"FeetechDriver.write([nan, 0, 0, 0, 0, 0]): ValueError: {e}; frames sent {len(writes(s))}; torque {d.extras()['torque']}")
dev cc15d9a7
run(hz=50.0, steps=50): 50 steps; attached True; goal/torque frames 51 in 1.004 s
run(hz=inf, steps=50): 50 steps; attached True; goal/torque frames 51 in 0.007 s
run(hz=nan, steps=50): 50 steps; attached True; goal/torque frames 51 in 0.007 s
run(hz=1000000.0, steps=50): 50 steps; attached True; goal/torque frames 51 in 0.007 s
subscribe('state', hz=50.0): accepted; bus reads in 0.2 s: 10
subscribe('state', hz=inf): accepted; bus reads in 0.2 s: 5382
subscribe('state', hz=nan): accepted; bus reads in 0.2 s: 5187
subscribe('state', hz=1000000.0): accepted; bus reads in 0.2 s: 4881
FeetechDriver.write([nan, 0, 0, 0, 0, 0]): ValueError: so101.shoulder_pan: non-finite goal nan; frames sent 1; torque True
this PR
run(hz=50.0, steps=50): 50 steps; attached True; goal/torque frames 51 in 1.027 s
run(hz=inf, steps=50): ValueError: hz must be a finite number > 0, got inf; attached False; goal/torque frames 0 in 0.000 s
run(hz=nan, steps=50): ValueError: hz must be a finite number > 0, got nan; attached False; goal/torque frames 0 in 0.000 s
run(hz=1000000.0, steps=50): 50 steps; attached True; goal/torque frames 51 in 0.009 s
subscribe('state', hz=50.0): accepted; bus reads in 0.2 s: 11
subscribe('state', hz=inf): ValueError: hz must be a finite number > 0, got inf; bus reads in 0.2 s: 0
subscribe('state', hz=nan): ValueError: hz must be a finite number > 0, got nan; bus reads in 0.2 s: 0
subscribe('state', hz=1000000.0): accepted; bus reads in 0.2 s: 4663
FeetechDriver.write([nan, 0, 0, 0, 0, 0]): ValueError: so101.shoulder_pan: non-finite goal nan; frames sent 0; torque False

python -c "import json; print(json.loads('{\"hz\": Infinity, \"h2\": NaN}'))" prints {'hz': inf, 'h2': nan}.
Simulation.run, whose lock-step loop this PR does not touch, python lockstep_hz.py <root>:

import math
import sys

sys.path.insert(0, sys.argv[1])
from strands_robots import Robot, Simulation  # noqa: E402

for hz in (math.inf, math.nan, 0.0, -5.0):
    sim = Simulation()
    arm = Robot("so101", sim=sim)
    t0 = sim.time
    try:
        res = sim.run({arm: lambda o: o.joints.positions}, hz=hz, steps=10)[arm.id]
        out = f"ran {res.steps} steps, hz_target {res.hz_target}"
    except Exception as e:  # noqa: BLE001
        out = f"{type(e).__name__}: {e}"
    print(f"Simulation.run(hz={hz}, steps=10): {out}; world time {t0:.3f} -> {sim.time:.3f} s")
    sim.close()
dev cc15d9a7
Simulation.run(hz=inf, steps=10): ZeroDivisionError: float division by zero; world time 0.000 -> 0.020 s
Simulation.run(hz=nan, steps=10): ValueError: cannot convert float NaN to integer; world time 0.000 -> 0.000 s
Simulation.run(hz=0.0, steps=10): ZeroDivisionError: float division by zero; world time 0.000 -> 0.000 s
Simulation.run(hz=-5.0, steps=10): ran 10 steps, hz_target -5.0; world time 0.000 -> 0.020 s
this PR
Simulation.run(hz=inf, steps=10): ZeroDivisionError: float division by zero; world time 0.000 -> 0.020 s
Simulation.run(hz=nan, steps=10): ValueError: cannot convert float NaN to integer; world time 0.000 -> 0.000 s
Simulation.run(hz=0.0, steps=10): ZeroDivisionError: float division by zero; world time 0.000 -> 0.000 s
Simulation.run(hz=-5.0, steps=10): ran 10 steps, hz_target -5.0; world time 0.000 -> 0.020 s

Tests and checks (this PR on dev cc15d9a).

Refs #3818

…ead of killing the process

strands-labs#4789 moved every render to the one 'strands-gl' thread. On macOS GLFW makes its window on the
main thread only, so with MUJOCO_GL=glfw the first render, even one asked for on the main thread,
ended the process with SIGTRAP: a child that renders a 64x48 camera exits -5, where the CGL
default renders (48, 64, 3). On macOS, when MuJoCo's GLContext is its glfw one, a render now runs
on the thread that asks. CGL, EGL and OSMesa keep the one GL thread, and MUJOCO_GL=disable still
says "rendering unavailable". This is GLFW as it was before strands-labs#4789 (measured at 269d54c): a main-
thread render works, a background start() after it renders too, and a first render from a worker
thread still dies with SIGTRAP, which is GLFW's rule.
…bration.py, beside the paths it reads

FeetechDriver._load_calibration built the list of candidate files, took the first that exists and
refused a missing calibration=<path>; calibration_candidates() wrapped lerobot_calibration_candidates
for it. That search is LeRobot's file naming, which drivers/calibration.py already holds
(lerobot_calibration_path, lerobot_calibration_candidates), so it moves there as the private
_find_lerobot_calibration(robot_type, robot_id, path) -> (found, looked), which feetech/driver.py
imports the way description.py imports _resolve_asset. FeetechDriver.calibration_candidates(), whose
only caller this was, goes. Nothing else changes: the same file loads, a missing path raises the same
FileNotFoundError, and calibration_looked lists the same paths. feetech/driver.py goes from 231 to
224 lines, which leaves room under the 250-line rule for the calibration fixes that follow and for
strands-labs#4795's read retry in the same file.
…bration file

Robot never handed its id to the driver, so a Feetech arm looked for the type-named file or the only
file in the directory. With my_arm.json and other_arm.json under so_follower/, Robot("so101",
port=, id="my_arm") loaded no calibration: act({"shoulder_pan": 1.0}) wrote Goal_Position 2700 (raw
full-turn counts) where my_arm.json maps it to 2581. With only other_arm.json there, it loaded that
other arm's file (2681). make_driver() now takes a keyword-only robot_id, which Robot(id=) fills, and
passes it to a driver class that takes one (Feetech), and with an id the search never guesses the
only file in a directory. Without id=, nothing changes. Measured on the suite's FakeSerial.

So an id with no file of its own now loads none, even where dev took the only file there: beside
only LeRobot's tutorial so_follower/my_awesome_follower_arm.json, Robot(id="left") sends
shoulder_pan's lower limit as 796 raw counts, where that file puts it at 1024. The search
(_find_lerobot_calibration) now warns then, naming the files in the directories it searched and
suggesting calibration=<path> or the id LeRobot calibrated the arm with. With nothing on disk it says
nothing, as before.
…cord over a file it only guessed

calibrate() always saved to default_calibration_path() (so101_follower/so101_follower.json), while
the servos held the new homing offsets. On the suite's FakeSerial, with so_follower/my_arm.json
loaded for id "my_arm": calibrate() wrote homing 301 to the servos and saved
so101_follower/so101_follower.json; the next session loaded my_arm.json again (homing 0, range
1024..3072), and shoulder_pan's lower limit went to encoder 1325, where the new calibration puts it
at 2148. calibrate() now saves over the file it loaded (named for the id, or given as
calibration=<path>), else to <lerobot_type>/<id>.json, and the next session's lower limit is
encoder 2148.

Without an id the search also loads the only *.json in a directory, which may be another arm's. A
save over that file would give the other arm this one's calibration, and a save beside it, to the
type-named file a connect without an id reads first, would hide it from every such connect. So for
a guessed file _find_lerobot_calibration returns no save target, and calibrate(interactive=True,
save=True) raises RuntimeError before torque goes off, naming the file and asking for id= or
calibration=. With only arm A's so_follower/arm_a.json on disk, arm B connected without an id loads
it; B's calibrate() now sends no bus frame and leaves arm_a.json the only file, and arm A, with or
without its id, still sends its lower limit to encoder 1024. On dev, B's next connect read
arm_a.json against B's new offsets (1325). Connected as id "my_arm", the arm whose file that was
saves over it, and the next connect reads 2148 with or without the id.

Acceptance check 9 reads the file the driver loaded (calibration_source), not the save path: with
LeRobot's so_follower/my_arm.json it skipped, and now it passes on the fake bus.
…g only after the middle pose

Interactive calibrate() turned torque off and zeroed every servo's Homing_Offset before its first
prompt, and replaced an arm's calibration without asking. On the suite's FakeSerial, with
so_follower/my_arm.json loaded (offsets 120): before the first answer, a Torque_Enable=0 frame and
6 Homing_Offset writes; Enter recorded a new calibration; Ctrl-C at that prompt left the servos at
offset 0 while the file says 120. The pinned LeRobot asks first when a calibration exists
(so_follower.py:139-147 at 53feaef3) and zeroes homing after the middle-pose Enter (:156-157). Now,
after strands-labs#4789's no-terminal guard, a loaded calibration is offered first ("Enter keeps it, c
re-records"): Enter returns it with nothing written to the bus or the file, where LeRobot's Enter
writes the kept calibration to the servos (:146). Torque goes off only after c, and the offsets are
zeroed only after the middle pose is confirmed, so a Ctrl-C at either prompt leaves the servos
holding what the file says (120). With save=True, a file loaded only as a guess is refused after
c, still before torque goes off. A Ctrl-C during the sweep, after the new offsets are written,
still leaves them in the servos beside the old file, as on dev.
… instead of sweeping it

LeRobot's so_follower never sweeps wrist_roll: it asks the operator to move every joint except
wrist_roll, and records 0..4095 for it (so_follower.py:160-168 at the pinned 53feaef3). calibrate()
swept every joint. On the suite's FakeSerial, a sweep that left wrist_roll still, as LeRobot asks,
recorded 2047..2047 for it: the next read() divided by zero, and wrist_roll's upper limit went out as
goal 2047. A sweep that turned it 200 counts each way recorded 1847..2247, and that limit went out as
2247. Now that calibrate() saves over the file it loaded, either range would also replace the
0..4095 in LeRobot's own so_follower/my_arm.json. calibrate() now leaves wrist_roll out of the sweep
and gives it 0..4095, and its prompt says so: both sweeps record 0..4095, read() is finite, and the
upper limit goes out as 4095. The homing offset is still set at the middle pose for every joint, as
LeRobot's set_half_turn_homings() does. wrist_roll is named in the driver, as LeRobot names it; so100
and so101, the registry's two feetech arms, both have one. tests/test_drivers.py gains SweepSerial,
a fake bus on which every joint but the still ones moves 200 counts each way.
…nge, and puts back the servos' offsets

calibrate() kept whatever the sweep recorded. A joint the operator never moved came out with
range_min == range_max, and a read() through that calibration divides by zero. On the suite's
FakeSerial, with so_follower/my_arm.json loaded for id "my_arm" and a sweep in which the gripper
never moves, calibrate() returned a gripper range of 2047..2047 and the same session's next read()
raised ZeroDivisionError; now that the save goes over the file it loaded, the next connect would
read that range too. A sweep with no reading at all (sweep_seconds=0) recorded range_min 4096 and
range_max 0, and shoulder_pan's lower limit then went out as goal 4096 and its upper limit as 0.
LeRobot refuses such a sweep of the joints it sweeps, every one but wrist_roll
(motors_bus.py:848-850 at the pinned 53feaef3, "Some motors have the same min and max values").
calibrate() now raises ValueError naming those joints before it builds the calibration: the file
and the driver's calibration stay as they were (gripper 1024..3072), and the next read() is finite.
wrist_roll, which the previous commit gives the full turn, is never among them.

By then the servos hold the new homing offsets, while the driver keeps the calibration it loaded.
With the new offsets left there, shoulder_pan's upper limit went to encoder 3373, 301 counts past
my_arm.json's stop at 3072 (homing 0, range 1024..3072), in the same session and at the next
connect (measured with the write-back below taken out). LeRobot's refusal leaves its new offsets
too, but set_half_turn_homings() has emptied the bus calibration first (reset_calibration(),
motors_bus.py:791 and :773), so no normalized read or write goes out after it (:855-856,
:884-885), and its next connect sees the mismatch and asks again (feetech.py:228-245,
so_follower.py:122-126). So calibrate() reads the servos' offsets just before it zeroes them, 6
more READ frames, and writes them back before it raises, 18 more WRITE frames: the servos then
hold what they held before (0 with that my_arm.json), and the upper limit lands on encoder 3072 in
the same session and at the next connect. A Ctrl-C during the sweep still leaves the new offsets,
as on dev. The new test loads a file recorded at offset 120 onto servos holding 120 and checks the
file, the driver's calibration and the servos' offsets after the refusal. The homing clamp's limit
variable is folded into its line: feetech/driver.py is 239 lines, and 250 with strands-labs#4795's read retry
merged. tests/test_drivers.py's calibrate test now sweeps on SweepSerial, 200 counts each way,
instead of holding still, and pins the range that records (1847..2247).
…ouches the bus

run() and the state stream checked hz <= 0; inf and nan pass both, and json.loads returns both for
Infinity and NaN. At hz=inf a tick lasts 0 s and at nan every wait is max(0.0, nan) = 0.0, so
nothing sleeps. On the suite's FakeSerial, on a shared machine, a hardware run(hz=inf, steps=50)
wrote its 51 goal and torque frames in 0.011 s (hz=50: 1.006 s), and subscribe("state", hz=inf)
read the bus 3621 times in 0.2 s (hz=50: 10). Both now refuse anything but a finite number > 0:
run(hz=) before bind, so a bad hz loads no checkpoint, and the stream before its thread starts.
The loop's _rate() keeps its own check: it only sees an hz run() has checked, or a rate the policy
declares, which bind() already refuses unless it is finite and > 0 (strands-labs#4786).
…que on

write() turned torque on first and only then built the goals, so the rad_to_counts check strands-labs#4789 added
refused a NaN after Torque_Enable=1 had gone to all six servos: on the suite's FakeSerial,
d.write([nan, 0, 0, 0, 0, 0]) raised "non-finite goal nan" with one torque-enable SYNC_WRITE sent
and torque left on. Robot.act never gets here with a NaN (coerce refuses it); a direct body.write
does. The goals are now built first: the same call writes nothing and torque stays off, and so does
a target vector of the wrong length (zip's ValueError, also after the torque write before).

@yinsong1986 yinsong1986 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Four fixes to the v1 Feetech/run surface, each with the fault reproduced on a fake bus first. Robot(id=) now reaches FeetechDriver as robot_id (via make_driver, only for a driver class whose signature takes it), so it names so_follower/<id>.json and never loads another arm's file; with an id and no file of its own it loads none and warns naming the files present. calibrate() saves over the file it loaded (else <type>/<id>.json), refuses with save=True to record over or beside a file it only guessed (no id, the one *.json in the directory), asks before torque goes off when a calibration is loaded, zeroes homing only after the middle pose is confirmed, gives wrist_roll the full turn as LeRobot does, and refuses a sweep that leaves a joint with lo >= hi - restoring the servos' prior homing offsets and leaving the loaded calibration in place, where dev wrote a 2047..2047 range that made read() divide by zero. run(hz=) and subscribe("state", hz=) reject inf/nan with not 0 < hz < inf, and FeetechDriver.write() builds the goal words before enabling torque so a NaN or wrong-length target leaves torque as it was. on_gl_thread runs inline when MUJOCO_GL=glfw on Darwin, which restores the pre-#4789 behaviour for the one platform where the dedicated GL thread trapped. Public signatures are only widened (make_driver(robot_id=)), the on-disk file format is unchanged, and the one behaviour change that can bite an existing caller (id= now selects the calibration file) is surfaced by a warning that names the fix rather than silently.

What's good

  • Every refusal is ordered before the side effect it guards: the guessed-file check precedes torque(False), the hz check precedes bind(), the NaN check precedes Torque_Enable, and the homing read-back happens before the first homing write.
  • tests/test_feetech_calibration.py asserts on bus frames and files, not driver internals, and the PR body records the red run of the new tests against dev.
  • The Darwin-only GLFW branch is pinned by a platform-agnostic routing test plus a MUJOCO_GL=disable child, so the macOS behaviour is graded on Linux CI without a GLFW window.

Verification suggestions

  • MUJOCO_GL=egl pytest -q tests/test_feetech_calibration.py tests/test_non_finite.py tests/test_drivers.py tests/test_render_thread.py (76 pass here; the rest of tests/ is green apart from the two pre-existing tests/recording child-process SIGSEGVs the PR body already lists).
  • On a Mac with glfw installed, the e_gl.sh probe from the PR body should print exit 0, printed '(48, 64, 3)' for MUJOCO_GL=glfw; dev prints exit 133.
  • The author's open question (should a fresh calibrate() save to so_follower/<id>.json as LeRobot 0.6 does, rather than so101_follower/<id>.json) is a maintainer call; the current choice keeps dev's path and is covered by the none-loaded test case.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants