15 · Safety: limits, torque caps, e-stop and watchdogs
| Part | 4 · Dynamics and control |
| Run type | CPU · Mac or Colab CPU |
| Prerequisites | 12 |
| Learn first | none |
| Time | ~40 min |
| Code | experiments/15-safety/experiment.py |
| Status | ✅ done |
Act on this lesson
Tick each step when you have done it. Saved in this browser only.
A learned policy will one day send a wrong number. Safety is the layer between that number and the motor that makes the wrong number cheap.
The problem
Section titled “The problem”LeRobot’s SO-101 follower has a setting whose comment says it exists “for safety purposes”: max_relative_target limits how far a goal may be from the present position, and it is off (None) by default (config_so_follower.py). The same codebase sets the gripper’s torque limit to 50% “to avoid burnout”. Neither says what happens when a policy outputs a bad target next to an obstacle. This lesson measures it in simulation: one bad command, several safeguards, the forces with and without each.
Before you read · 2 questions. Guess first; the lesson tells you why.
1. You cap a joint’s motor torque. Which harm does the cap bound directly?
- Both the impact and the push, equally; force is torque over lever either way.
- The steady push on an obstacle; the impact only indirectly, through a slower arrival.
- Only the impact; once the arm stalls the cap no longer matters.
Why: At rest the push is ; the impact depends on the arrival speed. Part A - a 0.5 N·m cap gives a 2.12 N push but still a 21.34 N peak.
2. An operator jogs a joint with velocity commands, then the messages stop arriving. With no watchdog, what does the arm do?
- It keeps moving at the last commanded velocity until something stops it.
- It holds its last position, like a position servo with no new goal.
- It drops, because the servo turns its torque off.
Why: “Stop” depends on the interface (Core idea). In part D, with no watchdog the arm travels 19.57° after the last message and hits the post with 24.48 N.
Core idea
Section titled “Core idea”In plain words
Section titled “In plain words”Give a learned policy what a new driver’s car has. A position limit is a fence: the goal never leaves a safe range. A rate limit is a speed limiter: the goal moves only a little per control tick (one cycle of the command loop). A torque cap is a slipping clutch: however wrong the goal, the motor pushes no harder. A watchdog is a train’s dead-man’s switch: no fresh command in time, stop. An E-stop pulls the plug, independent of the software it protects.
Two rules decide what each layer can do. First, a limit acts on one variable, not on harm: a torque cap bounds how hard the arm keeps pushing on an obstacle, but a fast arm still hits hard. Second, what “stop” means depends on the interface: a position servo that receives no new goal holds the last one; an arm driven by velocity commands keeps moving.
flowchart TD
P["policy output<br/>goal angle"] --> WD["watchdog<br/>no fresh message? hold"]
WD --> PL["position limit<br/>clamp to a safe range"]
PL --> RL["relative-target limit<br/>present ± δ per tick"]
RL --> SV["servo PD loop"]
SV --> TC["torque cap<br/>at most τmax"]
TC --> M["motor and arm"]
ES["hardware E-stop<br/>cuts power"] -.-> M
The safety layers as code
Section titled “The safety layers as code”def safe_goal(target, q_now, t_now, t_last_msg): if t_now - t_last_msg > watchdog_timeout: # stale command: stop where you are return q_now goal = np.clip(target, q_min, q_max) # position limit return np.clip(goal, q_now - delta, q_now + delta) # relative-target limit: <= delta rad per tick# torque cap: set once, enforced by the actuator (MuJoCo: forcerange; STS3215: a register)| Formula | What it bounds |
|---|---|
| goal | goal speed: at most |
| steady push on an obstacle at lever | |
| travel | distance the arm keeps moving before the watchdog fires |
| when true, a stalled servo still pushes at its cap despite the rate limit |
Here is the present joint angle, the largest allowed step of the goal per tick, the tick length (20 ms at 50 Hz), the torque cap, the lever from the joint axis to the contact, the contact force, the joint speed, the watchdog timeout and the servo’s stiffness (lesson 12).
Math details: why a torque cap bounds the push but not the impact
Steady push. At rest against the post the joint torque balances the contact moment, ; saturated at the cap, , whatever the speed or contact model.
Impact. The arm arrives with joint speed and inertia ; a contact spring of stiffness absorbs , so the peak grows with and . A torque cap limits it only indirectly (slower acceleration); a rate limit limits the arrival speed directly.
Rate limit and a stalled servo. Against an obstacle the position stops changing, the goal stays ahead, and the servo commands , which the cap clips to whenever .
Go deeper: LeRobot robots/utils.py (ensure_safe_goal_position); MuJoCo Modeling · actuators and forcerange; Lynch & Park, Modern Robotics, ch. 11.
Build it
Section titled “Build it”You will write the four layers in about 50 lines of NumPy and put them into the Experiment’s command loop. MuJoCo is the referee: same servo torques and, with your layers in the loop, the same tables. The finished functions are in physai/safety.py; uv run python experiments/15-safety/build.py runs the five steps in about 2 s (output in build_output.txt).
Step 1 · Clamp the goal
Section titled “Step 1 · Clamp the goal”def clamp_goal(target, q_now, q_min=None, q_max=None, delta=None): """Position limit, then relative-target limit: the goal stays in [q_min, q_max] and within delta of q_now.""" goal = target if q_min is not None or q_max is not None: goal = np.clip(goal, q_min, q_max) # a fence around the safe range if delta is not None: goal = np.clip(goal, q_now - delta, q_now + delta) # at most delta ahead of where the joint is now return goalFirst the fence, then the step relative to where the joint is now. Check: four hand cases, then 100,000 random targets against “present position plus a clipped difference”:
== Step 1. The goal clamp: position limit 0.3 rad, relative-target limit 0.02 rad == target q_now goal 1.20 0.00 0.020 1.20 0.29 0.300 0.25 0.24 0.250 -1.00 -0.10 -0.120100000 random: max |goal - q_now| = 0.0200 rad, outside range: 0, vs present + clip(diff): 1.7e-18goal speed bound delta / tick = 1.00 rad/sIn the second row the fence wins (0.300, not 0.31). The 1.00 rad/s bound applies to the goal, not the joint: Step 3 measures 1.82 rad/s.
Step 2 · Cap the torque
Section titled “Step 2 · Cap the torque”def servo_torque(goal, q, qd, kp, kv, tau_max): """A position servo with a torque cap: kp (goal - q) - kv qd, clipped to +-tau_max.""" return np.clip(kp * (goal - q) - kv * qd, -tau_max, tau_max)MuJoCo’s position actuator computes gainprm[0]·ctrl + biasprm[1]·q + biasprm[2]·qdot with biasprm = (0, −kp, −kv), after clamping ctrl to ctrlrange, and clips the result to forcerange. The check reads , and the ranges from the model; targets go far beyond the joint range:
== Step 2. The capped servo vs MuJoCo's position actuator (SO-101, 2000 random states) ==kp = 998.22, kv = 2.731, cap = 2.94 N m, pan ctrlrange = [-1.920, 1.920] radcap 2.94 N m: max |ours - MuJoCo| = 2.3e-13 N m; cap reached at an error of 2.95 mradcap 0.50 N m: max |ours - MuJoCo| = 2.2e-14 N m; cap reached at an error of 0.50 mradA joint at rest hits the 2.94 N·m cap at an error of 2.95 mrad, so against any bad command the cap, not the gains, sets the push.
Step 3 · Put the layers in the loop
Section titled “Step 3 · Put the layers in the loop”In the loop:
lim = (-q_lim, q_lim) if q_lim is not None else (None, None)d.ctrl[pan] = clamp_goal(target, d.qpos[0], *lim, delta) # every tickm.actuator_forcerange[pan] = [-cap, cap] # once, before the runThe check replays part A with your layers and the Experiment’s loop, then measures the contact lever :
== Step 3. Part A with our layers in the loop (bad command: pan target 0 -> 1.2 rad at t = 0.1 s) ==safeguard peak N push N speed tau max |ours - Exp.|none 125.98 14.33 4.49 2.94 0.0e+00limit 0.3 0.00 0.00 4.02 2.94 0.0e+00delta 0.02 45.82 14.48 1.82 2.94 0.0e+00cap 0.5 21.34 2.12 0.75 0.50 0.0e+00all three 0.00 0.00 0.75 0.50 0.0e+00steady push vs tau_max / r (r = lever of the post contacts about the pan axis): cap 2.94: r = 22.5 cm, tau_max / r = 13.07 N, push 14.33 N cap 1.00: r = 22.5 cm, tau_max / r = 4.44 N, push 4.34 N cap 0.50: r = 22.6 cm, tau_max / r = 2.22 N, push 2.12 Nstall test: kp * 0.005 = 4.99 N m >= 2.94 N mPart A’s table, bit for bit. With = 22.5 cm, predicts 2.22 and 4.44 N against 2.12 and 4.34 N measured, and 13.07 against 14.33 N at the full cap: the formula ignores the friction forces at the contact, which also carry moment about the axis. And 4.99 N·m ≥ 2.94 N·m: even part C’s tightest δ cannot bound the push.
Step 4 · The watchdog
Section titled “Step 4 · The watchdog”class Watchdog: """Expires when no fresh command has arrived for longer than `timeout` seconds."""
def __init__(self, timeout): self.timeout, self.last = timeout, None
def feed(self, t): """Call whenever a fresh command arrives at time t.""" self.last = t
def expired(self, t): return self.last is not None and t - self.last > self.timeoutFeed it on every message; on a silent tick, an expired watchdog zeroes the velocity and holds the present angle:
if msg is not None: wd.feed(t)elif wd.expired(t) and v_cmd != 0.0: v_cmd, target = 0.0, d.qpos[0] # stale command: stop and hold here== Step 4. Watchdog: jog at 1 rad/s, the last message at t = 0.18 s (part D) == timeout s stopped after s travel deg peak N max |ours - Exp.| none 0.350 19.57 24.48 0.0e+00 0.5 0.350 19.57 24.48 0.0e+00 0.2 0.230 11.61 0.00 0.0e+00 0.05 0.090 3.58 0.00 0.0e+00Part D’s rows again: 0.5 s is longer than the 0.350 s the arm needs to reach the post.
Step 5 · The E-stop latch
Section titled “Step 5 · The E-stop latch”class EStop: """A latch: one press stops the robot until someone resets it after the cause is gone."""
def __init__(self): self.tripped = False
def update(self, pressed): """Call every tick with the button (or fault) state; returns True while the stop is active.""" self.tripped = self.tripped or bool(pressed) return self.tripped
def reset(self, pressed): """Clear the latch, but only if the button is released.""" if not pressed: self.tripped = False return self.trippedA bouncing button or a fault that clears for one tick must not restart the arm; reset works only after release. The check presses for one tick, then replays part E with the latch:
== Step 5. E-stop latch, then part E: stop a 1 rad/s jog at t = 0.2 s ==pressed for one tick: active [False, True, True, True]; reset while pressed: True; after release: Falsestop travel deg stopped after s gripper drop cm max |ours - Exp.|hold 0.15 0.030 0.02 0.0e+00torque off 1.53 0.124 11.74 0.0e+00The latch decides whether to stop; how is part E’s choice. A real E-stop also cuts motor power in hardware.
What you keep
Section titled “What you keep”physai.safety now has clamp_goal, servo_torque, Watchdog and EStop; lesson 40 runs the real SO-101 behind them and lesson 41 puts them between a model’s output and the servos. Test: uv run pytest tests/test_15_safety.py.
Use it
Section titled “Use it”In MuJoCo two layers are model attributes enforced inside mj_step, and “torque off” is a flag:
m.actuator_ctrlrange[pan] = [-0.3, 0.3] # position limit on the goal (needs ctrllimited)m.actuator_forcerange[pan] = [-0.5, 0.5] # torque cap (needs forcelimited)m.opt.disableflags |= mujoco.mjtDisableBit.mjDSBL_ACTUATION # every actuator stops producing force== Use it. The position limit and the cap as model attributes (MuJoCo clamps inside mj_step) ==actuator_ctrlrange = [-0.3, 0.3]: peak 0.00 N, push 0.00 N; vs our layer 0.0e+00actuator_forcerange = [-0.5, 0.5]: peak 21.34 N, push 2.12 N; vs our layer 0.0e+00The step limit, watchdog and latch live in your loop, on the robot as in simulation.
| Layer | physai | MuJoCo | LeRobot SO-101 follower | libfranka |
|---|---|---|---|---|
| position limit | clamp_goal(q_min, q_max) | actuator ctrlrange + ctrllimited | ||
| step limit | clamp_goal(delta), against the present angle | max_relative_target → ensure_safe_goal_position, against Present_Position | limitRate(max_derivatives, commanded, last_commanded), against the last command | |
| torque cap | servo_torque(tau_max) | actuator forcerange + forcelimited; joint actuatorfrcrange | gripper Max_Torque_Limit 500 | |
| torque off | mjDSBL_ACTUATION | disable_torque_on_disconnect |
Sources: MuJoCo XML reference; LeRobot robots/utils.py, so_follower.py; libfranka rate_limiting.h.
Three traps:
- Against the present angle or the last command? LeRobot reads
Present_Positionbefore every send and clamps against it, asclamp_goaldoes. A limiter against the last command (libfranka’slimitRate) keeps advancing the goal while the joint is stalled; when the obstacle goes, the arm jumps to catch up. - Units. The SO-101 follower normalizes the five body joints to degrees by default (
use_degrees = True) and the gripper to 0–100, somax_relative_target = 0.02means 0.02°, not 0.02 rad. Andensure_safe_goal_positiontestsisinstance(max_relative_target, float): an integer such as5raisesTypeError. - A flag can switch a limit off.
ctrlrangeclamps only whilectrllimitedis on and the globalclampctrlflag is not disabled; otherwise the position limit silently disappears.
Check after building · 2 questions.
1. In Build it, Step 2, the SO-101 servo ( = 998.22) hits its 2.94 N·m cap at an error of 2.95 mrad. What follows for a bad command?
- The servo pushes in proportion to the error, so a larger bad command pushes harder.
- Lowering is the best way to bound the push.
- Almost any bad command saturates the servo, so the cap, not the gains, sets the push.
Why: Step 2 matches MuJoCo’s actuator to 2.3e-13 N·m and shows the 2.95 mrad threshold; Step 3 then measures pushes that follow the cap (2.12 N at 0.5 N·m, 4.34 N at 1.0 N·m).
2. With LeRobot’s default SO-101 follower settings you set max_relative_target = 0.02. What does it limit?
- Each body joint’s goal to 0.02 rad from the last command.
- Each body joint’s goal to 0.02° from the present position.
- The joint speed to 0.02 rad/s.
Why: Use it, traps - the follower uses degrees by default (use_degrees = True), and ensure_safe_goal_position clamps against Present_Position, like clamp_goal.
Experiment
Section titled “Experiment”Setup. SO-101 (Menagerie scene.xml), each joint the model’s STS3215 position servo ( = 998.22, = 2.731, cap ±2.94 N·m; lesson 12). Timestep 2 ms, command loop 50 Hz. Arm pose: shoulder_lift −0.5, elbow 0.5, wrist 0.8 rad; gripper frame at (29.4, −0.1, 12.8) cm. A fixed post (2 × 2 × 20 cm) stands in the gripper’s swing path at pan ≈ 0.6 rad. The loop is Build it’s Step 3; script: experiments/15-safety/experiment.py. MuJoCo 3.17.0, NumPy 2.5.4, Python 3.14, Apple M5, deterministic, ~3 s.
A. One bad command. At t = 0.1 s the shoulder_pan target jumps 0 → 1.2 rad. Predict: which single safeguard prevents the impact, which only softens it?
| safeguard | peak force on post | steady push force | peak pan speed | peak torque |
|---|---|---|---|---|
| none | 125.98 N | 14.33 N | 4.49 rad/s | 2.94 N·m |
| joint limit |pan| ≤ 0.3 rad | 0.00 | 0.00 | 4.02 | 2.94 |
| relative target ≤ 0.02 rad/tick | 45.82 | 14.48 | 1.82 | 2.94 |
| torque cap 0.5 N·m | 21.34 | 2.12 | 0.75 | 0.50 |
| all three | 0.00 | 0.00 | 0.75 | 0.50 |
Only the position limit prevents contact, and only because we knew where the obstacle was. The rate limit cuts the impact (45.82 vs 125.98 N) but not the steady push. The torque cap cuts both.
B. Sweep the torque cap (same bad command, nothing else):
| cap [N·m] | 2.94 | 2.00 | 1.00 | 0.50 | 0.25 | 0.10 |
|---|---|---|---|---|---|---|
| peak force [N] | 125.98 | 69.79 | 37.96 | 21.34 | 8.57 | 0.00 |
| steady push [N] | 14.33 | 8.73 | 4.34 | 2.12 | 1.48 | 0.00 |
| peak speed [rad/s] | 4.49 | 3.16 | 1.58 | 0.75 | 0.33 | 0.08 |
The cap also limits speed; at 0.10 N·m the arm does not reach the post within 2 s. Between 0.5 and 2 N·m the steady push is nearly proportional to the cap (2.12, 4.34, 8.73 N): with a lever of about 23 cm (Step 3 measures 22.5 cm).
C. Sweep the relative-target limit δ (goal clamped to present ± δ every 20 ms):
| δ [rad] | none | 0.2 | 0.05 | 0.02 | 0.01 | 0.005 |
|---|---|---|---|---|---|---|
| speed at impact [rad/s] | 4.494 | 4.494 | 2.866 | 1.513 | 0.929 | 0.359 |
| peak force [N] | 125.98 | 125.98 | 72.94 | 45.82 | 21.39 | 17.17 |
| steady push [N] | 14.33 | 14.33 | 13.54 | 14.48 | 14.67 | 13.54 |

Smaller δ lowers the impact speed and peak, but the steady push stays near 14 N: once the arm stalls, the goal is still present + δ, and ≥ 2.94 N·m for every δ here (998.22 × 0.005 ≈ 5 N·m).
D. Watchdog. The operator jogs shoulder_pan at 1 rad/s with velocity commands (the goal advances by per tick). The last message arrives at t = 0.18 s (pan 0.185 rad); the controller keeps that velocity until a watchdog holds the current position.
| timeout | stopped after the last message | max travel after it | peak force on post |
|---|---|---|---|
| none | 0.350 s (stopped by the post) | 19.57° | 24.48 N |
| 1 s | 0.350 s | 19.57° | 24.48 N |
| 0.5 s | 0.350 s | 19.57° | 24.48 N |
| 0.2 s | 0.230 s | 11.61° | 0.00 |
| 0.1 s | 0.130 s | 5.88° | 0.00 |
| 0.05 s | 0.090 s | 3.58° | 0.00 |
A watchdog only helps if it fires before the arm reaches the obstacle: here 0.5 s and 1 s are too slow.
E. Two ways to stop from the same 1 rad/s jog at t = 0.2 s:
| stop | max pan travel after the stop | pan stopped after | gripper drop |
|---|---|---|---|
| hold position (goal := present) | 0.15° | 0.030 s | 0.02 cm |
| torque off (actuation disabled) | 1.53° | 0.124 s | 11.74 cm |
Torque off stops the pan joint by friction alone and lets gravity take the shoulder: the gripper falls 11.74 cm, to the floor from 12.8 cm. Holding stops faster and keeps the arm up, but needs a working controller.
Break it
Section titled “Break it”Use the rate limiter as the only protection. Generous, δ = 0.2 rad per tick is 10 rad/s, more than the servo ever reaches (4.494 rad/s): the run equals no limiter (125.98 N). Tight, δ = 0.005 rad cuts the impact to 17.17 N, yet the arm keeps pushing with 13.54 N as long as the bad command lasts. A rate limit is not a contact-force limit; that is the torque cap’s job (B) or a model of where the obstacles are (A).
On the real robot
Section titled “On the real robot”- SO-101 / LeRobot.
max_relative_targetis off by default (Use it). At connect, LeRobot writes position-mode P/I/D coefficients 16/0/32 to every STS3215 and, for the gripper only,Max_Torque_Limit500,Protection_Current250 andOverload_Torque25 (so_follower.py).disable_torque_on_disconnect = Truemakes a disconnect part E’s “torque off”: expect the arm to drop. - Not measured on the real arm. Impact forces here depend on MuJoCo’s contact model and the 2 ms step. Procedure: put a kitchen scale or force sensor where the post is, jog slowly (not a step) into it at a reduced torque limit, record the peak; start with the lowest limit and never put a hand in the path. A hardware E-stop is separate from all software layers above.
Check yourself
Section titled “Check yourself”Quick check · 2 questions. Click an answer.
1. A tight relative-target limit (δ = 0.005 rad/tick) cuts the impact on the post to 17.17 N. Why does the arm still push on it with 13.54 N afterwards?
- Once the arm stalls, the goal stays δ ahead and (about 5 N·m) is above the 2.94 N·m cap, so the servo pushes at its cap.
- The rate limit only applies while the arm is moving freely.
- Gravity pulls the arm into the post.
Why: Part C - the steady push stays near 14 N for every δ (998.22 × 0.005 ≈ 5 N·m ≥ 2.94 N·m). A rate limit bounds the speed at impact, not the push.
2. A watchdog with a 0.5 s timeout guards a 1 rad/s jog, but the arm still hits the post. Why?
- The watchdog turns the torque off, so the arm falls into the post.
- Watchdogs only work with position commands.
- The arm reaches the post 0.350 s after the last message, before the timeout fires.
Why: Part D - a watchdog helps only if it fires before the arm can reach anything; 0.2 s and shorter avoided the post (0.00 N), 0.5 s and 1 s gave 24.48 N.
- Easy. With the torque cap at 1.0 N·m and a lever of 23 cm, what steady push force do you expect? Compare with part B.
- Medium. Why does δ = 0.02 rad/tick give a peak pan speed of 1.82 rad/s in part A, above the 1 rad/s that 0.02 rad per 20 ms suggests?
- Hard. Add a velocity-dependent torque cap (full cap below 0.2 rad/s, 0.5 N·m above) and rerun part A. Predict the impact peak and steady push first.
Answers
Section titled “Answers”- N; part B measures 4.34 N.
- The goal is relative to the present position and updated every 20 ms. Within a tick the servo sees an error of δ, and ≈ 20 N·m exceeds the 2.94 N·m cap, so it accelerates at full torque, passes the goal and brakes; the speed oscillates around δ/Δt = 1 rad/s with peaks above it.
- Not computed here; run it.
Takeaways and sources
Section titled “Takeaways and sources”- Each safeguard bounds one variable: a position limit prevents known collisions (0 N), a torque cap bounds the push (14.33 → 2.12 N at 0.5 N·m), a rate limit bounds the impact speed (4.494 → 0.359 rad/s) but not the push.
- A watchdog must fire before the arm can reach anything: 0.05–0.2 s avoided the post here, 0.5 s did not.
- “Stop” is a design choice: holding position stops in 0.030 s; torque off drops the gripper 11.74 cm.
Next: lesson 16 · Grasping and scripted pick-and-place.
Sources:
- LeRobot
config_so_follower.py,so_follower.py,robots/utils.py(Apache-2.0, read 2026-10-27). - libfranka
rate_limiting.h. - MuJoCo Modeling · actuators and
forcerange, XML reference,mj_contactForce; Menagerierobotstudio_so101. - Lynch & Park, Modern Robotics, ch. 11.
- All measured numbers:
experiments/15-safety/output.txtandbuild_output.txt.