Robot arm in MuJoCo
The grid arm reached its target past the wall by planning, by trying and by watching. But its world was only poses on paper.
What changes when the same arm has motors, thickness and a solid wall?
Overview
The mental model: a plan made on paper meets physics, so the arm must learn where the physics is. Trying costs crashes; watching costs a few demonstrations.
The task
Same two 10 cm links, same wall, same target at (14, 4) cm, same four commands: shoulder or elbow ±10°. Three things are new:
- Motors: a command sets target angles, and the arm swings there over 0.2 s, as in Robot basics.
- Thickness: the links are 4 mm bars, not lines.
- A solid wall: touching it ends the episode as a failure. On a real arm, that is a crash.
reached = bool(np.linalg.norm(self.hand() - W.TARGET) < 1.5)
terminated = touched or reached # a touch ends the episode as a failure
reward = 1.0 if reached and not touched else 0.0Full file · mujoco_arm.py · 225 lines
"""The grid arm's task in MuJoCo: the same two 10 cm links, barrier and target, now with motors, gravity, thickness
and a solid wall. Touching the wall ends the episode as a failure, as it would damage a real arm.
Run: python3 classes/ml/labs/robot-arm/mujoco_arm.py
The task is a Gymnasium environment (Gymnasium basics). Its commands are the grid's four: shoulder or elbow ±10°.
"""
import gymnasium as gym
import mujoco
import numpy as np
from gymnasium import spaces
import world as W
PERIOD = 0.2 # seconds of physics per command
CHECK = 5 # physics steps between wall checks (5 ms)
XML = """
<mujoco model="two-link arm and wall">
<option timestep="0.001" gravity="0 0 -9.81"/>
<worldbody>
<geom name="floor" type="plane" size="0.3 0.1 0.01" pos="0 0 -0.006"/>
<geom name="wall" type="box" pos="0.0975 0 0.025" size="0.0075 0.05 0.025"/>
<body name="upper_arm">
<joint name="shoulder" type="hinge" axis="0 -1 0" armature="0.02" range="-5 185"/>
<geom type="capsule" fromto="0 0 0 0.1 0 0" size="0.004" mass="0.20"/>
<body name="forearm" pos="0.1 0 0">
<joint name="elbow" type="hinge" axis="0 -1 0" armature="0.02" range="-185 5"/>
<geom type="capsule" fromto="0 0 0 0.1 0 0" size="0.004" mass="0.15"/>
<site name="hand" pos="0.1 0 0"/>
</body>
</body>
</worldbody>
<contact><exclude body1="upper_arm" body2="forearm"/></contact>
<actuator>
<position joint="shoulder" kp="6" kv="0.4"/>
<position joint="elbow" kp="6" kv="0.4"/>
</actuator>
</mujoco>
"""
def pose(cell):
"""Grid cell (row, column) → joint angles in degrees."""
return np.array([W.ANGLES[cell[0]], W.ELBOW[cell[1]]], dtype=float)
def cell(angles):
"""Joint angles → the nearest grid cell: how a grid policy reads a measured pose."""
return int(np.clip(round(angles[0] / 10), 0, 18)), int(np.clip(round((angles[1] + 180) / 10), 0, 18))
class WallReach(gym.Env):
"""Reach the grid's target (14, 4) cm past the wall. Observation: 2 angles (°), 2 speeds (°/s). Action: 4 commands."""
def __init__(self):
self.model = mujoco.MjModel.from_xml_string(XML)
self.data = mujoco.MjData(self.model)
self.wall = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "wall")
self.observation_space = spaces.Box(low=np.array([-5, -185, -360, -360], dtype=np.float32),
high=np.array([185, 5, 360, 360], dtype=np.float32))
self.action_space = spaces.Discrete(4)
self.trace = None # set to a list to record the pose every 20 ms
def reset(self, seed=None, options=None):
super().reset(seed=seed)
start = options["start"] if options else STARTS[self.np_random.integers(len(STARTS))]
mujoco.mj_resetData(self.model, self.data)
self.command = pose(start)
self.data.qpos[:] = self.data.ctrl[:] = np.radians(self.command)
mujoco.mj_forward(self.model, self.data)
return self.observe(), {}
def step(self, action):
self.command = np.clip(self.command + W.ACTIONS[action] * 10, [0, -180], [180, 0])
self.data.ctrl[:] = np.radians(self.command)
touched = False
for _ in range(round(PERIOD / self.model.opt.timestep) // CHECK):
mujoco.mj_step(self.model, self.data, nstep=CHECK)
touched = touched or self.touching_wall()
if self.trace is not None and round(self.data.time * 1000) % 20 == 0:
self.trace.append(np.degrees(self.data.qpos).round(2).tolist())
reached = bool(np.linalg.norm(self.hand() - W.TARGET) < 1.5)
terminated = touched or reached # a touch ends the episode as a failure
reward = 1.0 if reached and not touched else 0.0
return self.observe(), reward, terminated, False, {"touched": touched, "reached": reached and not touched}
def observe(self):
return np.concatenate([np.degrees(self.data.qpos), np.degrees(self.data.qvel)]).astype(np.float32)
def hand(self):
x, _, z = self.data.site_xpos[0]
return 100 * np.array([x, z])
def touching_wall(self):
return any(self.wall in (c.geom1, c.geom2) for c in self.data.contact[:self.data.ncon])
gym.register("WallReach-v0", entry_point=WallReach, max_episode_steps=60)
def touches_at_rest(start):
env = WallReach()
env.reset(options={"start": start})
return env.touching_wall()
STARTS = [s for s in W.STARTS if not touches_at_rest(s)] # the grid's starts that a 4 mm-thick arm can stand in
def run(policy, start, env=None):
"""One episode from `start`. Returns success, commands used, whether the wall was touched, and the poses."""
env = env or gym.make("WallReach-v0")
observation, _ = env.reset(options={"start": start})
poses = [observation[:2].tolist()]
for n in range(1, 61):
observation, reward, terminated, truncated, info = env.step(policy(observation))
poses.append(observation[:2].tolist())
if terminated or truncated:
return {"success": info["reached"], "commands": n, "touched": info["touched"], "poses": poses}
def table_policy(q):
"""Act from a 19 × 19 × 4 table: read the measured pose as its nearest grid cell."""
return lambda observation: int(np.argmax(q[cell(observation[:2])]))
def careful_planner(margin=1.0):
"""The grid's full-model planner, with the barrier made `margin` cm bigger on every side: a person keeping clear."""
blocked = np.array([[forbidden(a, b, margin) for b in W.ELBOW] for a in W.ANGLES])
goal = W.GOAL & ~blocked
values = np.zeros((19, 19))
for _ in range(200):
q = np.zeros((19, 19, 4))
for i in range(19):
for j in range(19):
for a, (di, dj) in enumerate(W.ACTIONS):
after = (i + di, j + dj)
if not (0 <= after[0] < 19 and 0 <= after[1] < 19) or blocked[after]:
after = (i, j)
q[i, j, a] = 1.0 if goal[after] else W.GAMMA * values[after]
values = q.max(axis=2)
return q
def forbidden(shoulder, elbow, margin):
joint, hand = W.forward(shoulder, elbow)
fraction = np.linspace(0, 1, 60)[:, None]
points = np.concatenate([fraction * joint, joint + fraction * (hand - joint)])
near_wall = ((points[:, 0] >= 9 - margin) & (points[:, 0] <= 10.5 + margin)
& (points[:, 1] >= 0) & (points[:, 1] <= 5 + margin)).any()
return bool((points[:, 1] < -1e-9).any() or near_wall)
def q_learning(seed, episodes=300):
"""The Q-table workshop's learner, now trying inside MuJoCo from home. A wall touch ends the try with nothing."""
rng = np.random.default_rng(seed)
q = np.zeros((19, 19, 4))
env = gym.make("WallReach-v0", max_episode_steps=400)
commands = crashes = 0
first = None
for episode in range(episodes):
observation, _ = env.reset(options={"start": W.HOME})
state = cell(observation[:2])
while True:
action = int(rng.integers(4)) if rng.random() < 0.2 else int(np.argmax(q[state] + 1e-9 * rng.random(4)))
observation, reward, terminated, truncated, info = env.step(action)
after = cell(observation[:2])
target = reward + (0 if terminated else W.GAMMA * q[after].max())
q[state][action] += 0.5 * (target - q[state][action])
commands += 1
crashes += info["touched"]
state = after
if info["reached"] and first is None:
first = episode + 1
if terminated or truncated:
break
return {"q": q, "commands": commands, "crashes": crashes, "first_success": first}
def demonstrate(starts, expert):
"""Record the expert driving the arm in MuJoCo: (measured pose as a grid position, command) at every step."""
pairs, commands = [], 0
env = gym.make("WallReach-v0")
for start in starts:
observation, _ = env.reset(options={"start": start})
while True:
action = int(np.argmax(expert[cell(observation[:2])]))
pairs.append(((observation[0] / 10, (observation[1] + 180) / 10), action))
observation, reward, terminated, truncated, info = env.step(action)
commands += 1
if terminated or truncated:
break
return pairs, commands
def network_policy(policy):
"""Act from a trained 2 → 16 → 4 network, fed the measured angles (not rounded to the grid)."""
import torch
def act(observation):
with torch.no_grad():
x = torch.tensor(W.encode([[observation[0] / 10, (observation[1] + 180) / 10]]))
return int(policy(x).argmax())
return act
if __name__ == "__main__":
print(f"{len(W.STARTS)} grid starts; {len(W.STARTS) - len(STARTS)} touch the wall at rest; {len(STARTS)} remain")
for name, q in (("grid planner", W.reference()), ("careful planner", careful_planner())):
runs = [run(table_policy(q), s) for s in STARTS]
print(f"{name:16s} {sum(r['success'] for r in runs)}/{len(STARTS)} reach the target; "
f"{sum(r['touched'] for r in runs)} touch the wall")
import imitation
learned = q_learning(seed=0)
runs = [run(table_policy(learned["q"]), s) for s in STARTS]
print(f"learning by trying (seed 0, 300 tries from home): {learned['commands']} commands, {learned['crashes']} wall crashes; "
f"home {'reached' if run(table_policy(learned['q']), W.HOME)['success'] else 'failed'}, {sum(r['success'] for r in runs)}/{len(STARTS)} starts")
rng = np.random.default_rng(0)
pairs, commands = demonstrate([STARTS[i] for i in rng.choice(len(STARTS), 10, replace=False)], careful_planner())
policy = imitation.train(pairs, seed=0)
runs = [run(network_policy(policy), s) for s in STARTS]
print(f"learning by watching (10 demonstrations, {commands} commands): {sum(r['success'] for r in runs)}/{len(STARTS)} starts, "
f"{sum(r['touched'] for r in runs)} touch the wall")
MuJoCo’s window
MuJoCo comes with its own viewer: a 3D window where the physics runs live and you can push the arm yourself.

python3 classes/ml/labs/robot-arm/view_arm.py- Look around: drag with the left mouse button to turn, scroll to zoom.
- Push the arm: double-click a link, then hold Ctrl and drag with the right button into the wall.
- Command it: the two Control sliders are the motors’ target angles, in radians. Move one and watch the arm chase it.
The grid's plan
The grid’s planner knew the route exactly. Play it here, then compare the other three.
Four ways from home
- Grey ghosts are the arm after each earlier command.
- Red marks a touch.
But that needed an exact model of the arm and someone who chose the margin. A real robot has neither, so it must learn.
Learning by trying
The Q-table learner from Robot arm · Q-table, unchanged, now trying inside MuJoCo: 300 tries from home, a crash ends a try with nothing.
observation, reward, terminated, truncated, info = env.step(action)
after = cell(observation[:2])
target = reward + (0 if terminated else W.GAMMA * q[after].max())
q[state][action] += 0.5 * (target - q[state][action])
commands += 1
crashes += info["touched"]Full file: mujoco_arm.py, in its first card above ↑
Learning by watching
Now a careful demonstrator drives instead: the planner with a 1 cm margin, standing in for a person. The network from Robot arm · imitation learning copies its commands from the measured angles.
The bill
In MuJoCo both bills are cheap: the computer pays them in minutes. On a real arm, every crash is real and every try needs a reset by hand.
That is why your SO-101 learns by watching: a person shows the task, and ACT copies it.
Recap
WallReachis the grid task as a Gymnasium environment, with motors, thickness and a wall that ends the episode.table_policy(W.reference()), the grid’s plan, reaches only where it never scrapes the wall.careful_planner()works everywhere, but only because it has the exact model and a chosen margin.q_learning(seed)learns by trying: thousands of commands and dozens of crashes for one route.demonstrate(starts, expert)andimitation.trainlearn by watching: a few demonstrations for most starts.
Next, the same idea at full size: LeRobot data and ACT.
Key concepts
- Contact
- Two shapes touching in the simulator; here, a crash.
- Model gap
- What a plan’s model leaves out: here, thickness and the motion between poses.
- Sample cost
- How many tries a learner needs, and what they cost on a real robot.
- Demonstration
- One episode driven by an expert, recorded as observations and commands.