A robot model is a list of claims about a machine: where its joints are and which way they turn, how far they can turn, how its mass is distributed, how much force each motor can produce, and where the points that matter (the tool tip, a camera, a target) sit. Each wrong claim fails in its own way. A wrong joint axis shows up the first time you move. A wrong mass shows up only when you accelerate, or when a model-based controller computes torques from it. A wrong motor limit shows up only at the edge of the workspace, usually during a demonstration.
This lesson writes those claims for the simplest arm worth the name, two links in a vertical plane, and checks each one against a hand calculation. Every later lesson in the robotics track starts from a model you can check this way.
[!established] Where each claim lives in MJCF Joint position and axis:
<joint pos axis>inside the child body. Travel:range, which also setslimitedbecause the compiler’sautolimitsis on by default. Mass distribution:<inertial pos mass diaginertia>; without it MuJoCo infers mass and inertia from the body’s geoms atdensity="1000". Motor strength:ctrlrange(andforcerange), which setctrllimitedthe same way. Named points:<site>, a frame with no mass and no collision that sensors, Jacobians and controllers can refer to by name.
arm2.xml makes these choices: both hinges turn about $-y$ so that a positive angle raises a link in the $x$-$z$ plane; link lengths 0.5 m and 0.4 m and masses 1.0 kg and 0.8 kg, given explicitly as thin rods; a shoulder stop at $-1.2$ rad so the arm rests on it rather than hanging through its pedestal; motors limited to 25 N m and 15 N m; and one site, ee, at the tip. None of its geoms collide with anything, because this model exists for kinematics, dynamics and control (contact arrives with the gripper in Lesson 5.3).
The lab applies a constant torque at each joint, plus, if you switch it on, a gravity-compensation term equal to MuJoCo’s own qfrc_bias. Try four things.
```lab simlab
{“dock”: true, “title”: “A torque budget for a two-link arm”, “model”: “arm2”, “height”: 280,
“camera”: {“azimuth”: -90, “elevation”: 0, “distance”: 2.3, “target”: [0.15, 0, 1.0]},
“toggles”: [“frames”, “joints”, “com”, “sites”], “overlays”: {“sites”: true}, “trail”: “ee”,
“controls”: [
{“label”: “shoulder torque”, “unit”: “N m”, “param”: “t1”, “min”: -25, “max”: 25, “step”: 0.1, “value”: 0, “digits”: 1},
{“label”: “elbow torque”, “unit”: “N m”, “param”: “t2”, “min”: -15, “max”: 15, “step”: 0.1, “value”: 0, “digits”: 1},
{“type”: “select”, “label”: “add gravity compensation”, “param”: “gc”, “options”: [[“off”, 0], [“on”, 1]], “value”: 0},
{“type”: “button”, “label”: “Arm horizontal”, “run”: “data.qpos[0] = 0; data.qpos[1] = 0; data.qvel.fill(0)”},
{“type”: “button”, “label”: “Bent pose”, “run”: “data.qpos[0] = 0.6; data.qpos[1] = -1.2; data.qvel.fill(0)”}],
“controller”: “data.ctrl[0] = ctx.t1 + ctx.gc * data.qfrc_bias[0]; data.ctrl[1] = ctx.t2 + ctx.gc * data.qfrc_bias[1]”,
“readouts”: [
{“label”: “qfrc_bias, shoulder (N m)”, “expr”: “data.qfrc_bias[0]”, “digits”: 3},
{“label”: “qfrc_bias, elbow (N m)”, “expr”: “data.qfrc_bias[1]”, “digits”: 3},
{“label”: “applied, shoulder (N m)”, “expr”: “data.actuator_force[0]”, “digits”: 2},
{“label”: “applied, elbow (N m)”, “expr”: “data.actuator_force[1]”, “digits”: 2},
{“label”: “limit force, shoulder (N m)”, “expr”: “data.qfrc_constraint[0]”, “digits”: 3}],
“plots”: [{“ylabel”: “angle (rad)”, “window”: 8, “traces”: [
{“label”: “shoulder (rad)”, “expr”: “data.qpos[0]”}, {“label”: “elbow (rad)”, “expr”: “data.qpos[1]”, “dash”: true}]}],
“note”: “At rest, qfrc_bias is the gravity torque; while the arm moves it also contains Coriolis and centrifugal terms (Level 7). Gravity compensation here adds all of qfrc_bias, so the arm moves as if it had no weight and no velocity coupling. The yellow trail follows the ee site.”}
## Mathematics
### Where the tip is
With both angles measured from the horizontal and the shoulder axis at the origin of `link1`'s frame,
$$x = L_1\cos q_1 + L_2\cos(q_1 + q_2), \qquad z = L_1 \sin q_1 + L_2\sin(q_1 + q_2).$$
Lesson 6.1 builds this up as a chain of transforms. Here it is a test: if `site_xpos` for `ee` disagrees with it at any pose, a joint axis, a body offset or a site position in the model is wrong.
### What the arm weighs at each joint
Holding the arm still takes a torque equal to the derivative of its potential energy. With centres of mass at $c_1 = 0.25$ m and $c_2 = 0.2$ m from each joint,
$$V(q) = g\left[m_1 c_1 \sin q_1 + m_2\left(L_1 \sin q_1 + c_2\sin(q_1+q_2)\right)\right],$$
$$\tau_{g,1} = \frac{\partial V}{\partial q_1} = g\left[(m_1 c_1 + m_2 L_1)\cos q_1 + m_2 c_2 \cos(q_1 + q_2)\right], \qquad \tau_{g,2} = \frac{\partial V}{\partial q_2} = g\, m_2 c_2 \cos(q_1 + q_2).$$
Both are largest with the arm horizontal: $\tau_{g,1} = 9.81 \times (0.25 + 0.4 + 0.16)$ = 7.946 N m and $\tau_{g,2} = 9.81 \times 0.16$ = 1.570 N m. MuJoCo reports exactly these numbers in `qfrc_bias` when the arm is at rest. The sign convention is the one in MuJoCo's equation of motion, $M\ddot q + \text{qfrc\_bias} = \tau$: the bias is the torque the actuators must *supply*.
### Sizing a motor
A motor must hold the arm against gravity, accelerate it, and carry a payload. At the horizontal pose, with the elbow held rigid ($\ddot q_2 = 0$), the shoulder equation reduces to $\tau_1 - \tau_{g,1} = M_{11}\,\ddot q_1$, where $M_{11}$ is the arm's inertia about the shoulder:
$$M_{11} = I_1 + I_2 + m_1 c_1^2 + m_2\left(L_1^2 + c_2^2 + 2 L_1 c_2 \cos q_2\right) = 0.0208 + 0.0107 + 0.0625 + 0.8 \times 0.49 = 0.486 \text{ kg m}^2 .$$
So 25 N m leaves 17.05 N m for acceleration, 35 rad/s². A point payload $m_p$ at the tip adds $g\,m_p (L_1 + L_2)$ at the shoulder and $g\,m_p L_2$ at the elbow; solving for the largest $m_p$ each motor can hold gives 1.93 kg (shoulder) and 3.42 kg (elbow). The shoulder is the binding joint, as it is on almost every arm.
> [!recommendation] Write the torque budget down
> For each joint, record the worst-case gravity torque, the ratio of the motor limit to it, and the payload it leaves. A ratio near 1 means the arm can hold itself up and do nothing else; this arm uses about 3 at the shoulder and 10 at the elbow. Take the limits from the real actuator's continuous rating, not its peak, when the model is meant to stand in for hardware.
### Inertia: explicit or inferred
A thin rod of mass $m$ and length $L$ has inertia $mL^2/12$ about its centre, perpendicular to its length: 0.0208 kg m² for link 1 and 0.0107 kg m² for link 2, the values in `arm2.xml`. Without `<inertial>`, MuJoCo would treat each capsule as solid material at 1000 kg/m³, which gives 1.047 kg and 0.536 kg instead. The two models move differently, and a controller tuned on one is mistuned on the other.
### Joint stops are soft constraints
A joint limit is a constraint of the same kind as a contact, solved by the same solver with its own `solreflimit` and `solimplimit`. Like a contact, it is soft: at rest on the stop, the joint sits slightly past it, and the constraint force equals whatever the stop is holding up. Level 9 derives how far; here, measure it.
## Implementation
```io
INPUT: arm2.xml
PROCESS: inspect the compiled model; check forward kinematics and gravity torque against the formulas above; size the motors; compare explicit and inferred inertias; rest the arm on its shoulder stop; overdrive the motors
OUTPUT: printed tables
```python file=examples/l5_1_two_link.py “"”Lesson 5.1: a two-link arm, checked the way you would check any robot model.
INPUT arm2.xml (planar arm, explicit inertias, joint stops, torque motors) PROCESS (1) print what the compiled model contains: joints, axes, ranges, motors, sites; (2) compare the end-effector site with the planar forward-kinematics formula; (3) compare gravity torque (qfrc_bias at rest) with the hand formula, and size the motors: margin over gravity, payload, peak acceleration; (4) compare the explicit inertias with what MuJoCo infers from the geoms, and show that a visual-only geom still adds mass; (5) let the arm fall onto its shoulder stop and measure the soft limit; (6) command more torque than ctrlrange allows OUTPUT printed tables
Run: python examples/l5_1_two_link.py “””
import mujoco import numpy as np
from mjcourse import model_path
G = 9.81 L1, L2 = 0.5, 0.4 # link lengths (m) M1, M2 = 1.0, 0.8 # link masses (kg) C1, C2 = 0.25, 0.2 # centre-of-mass distance from each joint (m)
def load() -> tuple[mujoco.MjModel, mujoco.MjData]: model = mujoco.MjModel.from_xml_path(str(model_path(“arm2”))) return model, mujoco.MjData(model)
def structure(model: mujoco.MjModel) -> None: for j in range(model.njnt): jn = model.joint(j) print(f” joint {jn.name:<9} axis {jn.axis} range {jn.range} rad limited {bool(model.jnt_limited[j])}” f” damping {model.dof_damping[model.jnt_dofadr[j]]} N m s/rad”) for a in range(model.nu): act = model.actuator(a) print(f” motor {act.name:<9} gear {act.gear[0]:g} ctrlrange {act.ctrlrange} N m ctrllimited {bool(model.actuator_ctrllimited[a])}”) print(f” sites: {[model.site(i).name for i in range(model.nsite)]}”)
def forward_kinematics(model, data) -> None: rng = np.random.default_rng(0) base = np.array([0.0, 0.0, 1.0]) # link1’s frame: the shoulder axis worst = 0.0 for _ in range(1000): q = rng.uniform(model.jnt_range[:, 0], model.jnt_range[:, 1]) data.qpos[:] = q mujoco.mj_kinematics(model, data) x = L1 * np.cos(q[0]) + L2 * np.cos(q[0] + q[1]) z = L1 * np.sin(q[0]) + L2 * np.sin(q[0] + q[1]) worst = max(worst, np.abs(data.site(“ee”).xpos - (base + [x, 0.0, z])).max()) print(f” 1000 random poses within the joint ranges: max |site_xpos - formula| = {worst:.1e} m”)
def gravity_torque(q: np.ndarray) -> np.ndarray: “"”Torque the motors must supply to hold the arm still at q (the derivative of potential energy).””” t2 = G * M2 * C2 * np.cos(q[0] + q[1]) t1 = G * (M1 * C1 + M2 * L1) * np.cos(q[0]) + t2 return np.array([t1, t2])
def motor_sizing(model, data) -> None: rng = np.random.default_rng(1) worst = 0.0 for _ in range(1000): q = rng.uniform(model.jnt_range[:, 0], model.jnt_range[:, 1]) data.qpos[:] = q data.qvel[:] = 0 mujoco.mj_forward(model, data) worst = max(worst, np.abs(data.qfrc_bias - gravity_torque(q)).max()) print(f” 1000 random poses at rest: max |qfrc_bias - formula| = {worst:.1e} N m”)
data.qpos[:] = 0 # arm horizontal: the largest gravity torque at both joints
data.qvel[:] = 0
mujoco.mj_forward(model, data)
tau_g = data.qfrc_bias.copy()
limit = model.actuator_ctrlrange[:, 1]
print(f" gravity torque, arm horizontal: shoulder {tau_g[0]:.3f} N m, elbow {tau_g[1]:.3f} N m")
print(f" motor limit / gravity torque: shoulder {limit[0] / tau_g[0]:.2f}, elbow {limit[1] / tau_g[1]:.2f}")
payload = np.array([(limit[0] - tau_g[0]) / (G * (L1 + L2)), (limit[1] - tau_g[1]) / (G * L2)])
print(f" largest point payload at the tip, arm horizontal: shoulder allows {payload[0]:.2f} kg, "
f"elbow allows {payload[1]:.2f} kg -> {payload.min():.2f} kg")
mass = np.zeros((model.nv, model.nv))
mujoco.mj_fullM(model, data, mass) # 3.10+: reads the factorized M from data
acc = (limit[0] - tau_g[0]) / mass[0, 0] # elbow held rigid: qacc_elbow = 0
elbow = mass[1, 0] * acc + tau_g[1] # elbow torque that keeps it rigid
print(f" M(q) at this pose: {np.round(mass, 4).tolist()} kg m^2")
print(f" spare shoulder torque {limit[0] - tau_g[0]:.2f} N m gives {acc:.1f} rad/s^2 with the elbow rigid, "
f"which needs {elbow:.2f} N m at the elbow")
def inferred_inertia(model) -> None: xml = model_path(“arm2”).read_text() lines = [ln for ln in xml.splitlines() if “<inertial” not in ln] inferred = mujoco.MjModel.from_xml_string(“\n”.join(lines)) for name in (“link1”, “link2”): a, b = model.body(name), inferred.body(name) print(f” {name}: explicit {a.mass[0]:.3f} kg, principal moments {np.round(np.sort(a.inertia), 5)} | “ f”from geoms {b.mass[0]:.3f} kg, {np.round(np.sort(b.inertia), 5)} kg m^2”) d = mujoco.MjData(inferred) mujoco.mj_forward(inferred, d) print(f” gravity torque at the horizontal pose: explicit {gravity_torque(np.zeros(2))[0]:.3f} N m, “ f”from geoms {d.qfrc_bias[0]:.3f} N m (shoulder)”)
def visual_geom_mass() -> None:
xml = (‘
def joint_stop(model, data) -> None: mujoco.mj_resetData(model, data) for _ in range(round(60.0 / model.opt.timestep)): # damping is light: let the elbow settle mujoco.mj_step(model, data) lo = model.jnt_range[0, 0] limit_rows = [i for i in range(data.nefc) if data.efc_type[i] == mujoco.mjtConstraint.mjCNSTR_LIMIT_JOINT] print(f” after 60 s with zero torque: shoulder at {data.qpos[0]:.5f} rad, stop at {lo} rad “ f”-> {1000 * (lo - data.qpos[0]):.2f} mrad past the stop”) print(f” active limit rows: {len(limit_rows)}; qfrc_constraint[shoulder] = {data.qfrc_constraint[0]:.4f} N m, “ f”gravity torque qfrc_bias[shoulder] = {data.qfrc_bias[0]:.4f} N m”)
def clamping(model, data) -> None: mujoco.mj_resetData(model, data) data.ctrl[:] = [100.0, -100.0] mujoco.mj_forward(model, data) print(f” ctrl = {data.ctrl}: actuator_force = {data.actuator_force} N m (no warning is raised)”)
if name == “main”: model, data = load() print(“(1) what the compiled model contains:”) structure(model) print(“(2) forward kinematics of the ee site:”) forward_kinematics(model, data) print(“(3) gravity torque and motor sizing:”) motor_sizing(model, data) print(“(4) explicit inertias versus inertias inferred from the geoms:”) inferred_inertia(model) visual_geom_mass() print(“(5) the shoulder stop is a soft constraint:”) joint_stop(model, data) print(“(6) commands beyond ctrlrange are clamped:”) clamping(model, data)
Output:
```text
(1) what the compiled model contains:
joint shoulder axis [ 0. -1. 0.] range [-1.2 2.4] rad limited True damping 0.02 N m s/rad
joint elbow axis [ 0. -1. 0.] range [-2.6 2.6] rad limited True damping 0.02 N m s/rad
motor shoulder gear 1 ctrlrange [-25. 25.] N m ctrllimited True
motor elbow gear 1 ctrlrange [-15. 15.] N m ctrllimited True
sites: ['ee']
(2) forward kinematics of the ee site:
1000 random poses within the joint ranges: max |site_xpos - formula| = 4.4e-16 m
(3) gravity torque and motor sizing:
1000 random poses at rest: max |qfrc_bias - formula| = 2.7e-15 N m
gravity torque, arm horizontal: shoulder 7.946 N m, elbow 1.570 N m
motor limit / gravity torque: shoulder 3.15, elbow 9.56
largest point payload at the tip, arm horizontal: shoulder allows 1.93 kg, elbow allows 3.42 kg -> 1.93 kg
M(q) at this pose: [[0.486, 0.1227], [0.1227, 0.0427]] kg m^2
spare shoulder torque 17.05 N m gives 35.1 rad/s^2 with the elbow rigid, which needs 5.87 N m at the elbow
(4) explicit inertias versus inertias inferred from the geoms:
link1: explicit 1.000 kg, principal moments [0.0001 0.02083 0.02083] | from geoms 1.047 kg, [0.00032 0.02502 0.02502] kg m^2
link2: explicit 0.800 kg, principal moments [0.0001 0.01067 0.01067] | from geoms 0.536 kg, [0.00011 0.0082 0.0082 ] kg m^2
gravity torque at the horizontal pose: explicit 7.946 N m, from geoms 6.250 N m (shoulder)
1 kg collision geom + 2 kg non-colliding geom in group 3, default inertiagrouprange (0 5): body mass 3.000 kg
1 kg collision geom + 2 kg non-colliding geom in group 3, inertiagrouprange="0 2": body mass 1.000 kg
(5) the shoulder stop is a soft constraint:
after 60 s with zero torque: shoulder at -1.20053 rad, stop at -1.2 rad -> 0.53 mrad past the stop
active limit rows: 1; qfrc_constraint[shoulder] = 2.3074 N m, gravity torque qfrc_bias[shoulder] = 2.3074 N m
(6) commands beyond ctrlrange are clamped:
ctrl = [ 100. -100.]: actuator_force = [ 25. -15.] N m (no warning is raised)
Three lines in this output carry the lesson.
Lines (2) and (3) agree with the formulas to rounding error, $10^{-16}$ m and $10^{-15}$ N m over a thousand poses. Agreement at this level is the expected result for a correct model; agreement to $10^{-3}$ would mean something is subtly wrong, such as a body offset typed with one digit missing.
Line (4) shows the inferred model is 21% lighter at the shoulder (6.250 against 7.946 N m), because link 2 as a solid capsule weighs 0.536 kg instead of 0.8 kg. Nothing in the simulation would warn you: both models are valid, only one is the robot.
Line (5) shows the stop is soft and exact at once. The shoulder rests 0.53 mrad past $-1.2$ rad, and the force the limit applies, 2.3074 N m, equals the gravity torque at that pose to every printed digit. At this angle the arm points steeply down, so gravity’s lever arm, and therefore its torque, is smaller than at the horizontal.
[!warning] Clamping is silent Line (6): a command of 100 N m produces 25 N m with no warning. A controller that asks for more than the motor can give fails quietly, and its logs, if they record
ctrl, show the request instead of the result. Logactuator_force.
A positive torque lowers the arm. The joint axis has the opposite sign to the one your formulas assume. Print model.jnt_axis and decide on one convention for the whole model; arm2.xml uses $-y$ so that positive raises.
The arm sags although the controller computes enough torque. ctrlrange is clamping it (output 6). Compare data.ctrl with data.actuator_force at the moment it sags.
The arm’s dynamics changed when someone replaced a visual mesh. The body has no <inertial>, so its mass came from its geoms, and the new mesh has a different volume. Visual-only geoms (contype="0" conaffinity="0") still add mass unless their group lies outside compiler/inertiagrouprange (default 0 5); the last two lines of output (4) show it. Give every body of a robot an explicit <inertial>.
The joint goes a few milliradians past its limit. That is the soft limit at work (output 5), not a bug. Stiffen solreflimit if it matters for your task, and remember that MuJoCo will not let the time constant drop below twice the timestep while the refsafe flag is on.
Add a third link to arm2.xml: 0.15 m long, 0.3 kg, with an explicit thin-rod inertia, a hinge about $-y$ with range $\pm 2$ rad, a motor limited to three times its worst-case gravity torque, and the ee site moved to its tip. Recompute the shoulder’s torque ratio by hand, then check it with qfrc_bias.
With the shoulder motor limited to 6 N m, less than the 7.946 N m the straight arm weighs at the horizontal, map every tip position $(x, z)$ the arm can hold still. Plot it over the full kinematic workspace. Explain the shape of the region using $\tau_{g,1}$ and the elbow angle, and find the farthest reachable horizontal distance from the shoulder.
[!research] Models are hypotheses about hardware Policies trained in simulation inherit every number in the model: link masses, actuator limits, joint damping. Of these, inertial parameters and actuator characteristics are the ones most often guessed (inference: they are the hardest to measure and the easiest to leave at a default), and they are exactly the numbers the checks in this lesson expose. Level 18 estimates them from data; Level 19 asks how much of a sim-to-real gap they explain. Before either, the cheapest test is the one above: forward kinematics against a measured pose, and gravity torque against the holding torque the real robot reports.
{"id": "5.1-check", "title": "Knowledge check", "questions": [
{"kind": "numeric", "q": "A single link of mass 2 kg with its centre of mass 0.3 m from a horizontal hinge is held horizontal. What torque must the motor supply, in N m? (g = 9.81 m/s²)",
"answer": 5.886, "tol": 0.01, "unit": "N m",
"explain": "<p>$\\tau = m g c = 2 \\times 9.81 \\times 0.3$ = 5.886 N m. This is what <code>qfrc_bias</code> reports for that joint at rest.</p>"},
{"kind": "mcq", "q": "A body has two geoms: a collision box and a detailed visual mesh with <code>contype=\"0\" conaffinity=\"0\"</code> in group 1, and no <code><inertial></code>. Which geoms contribute to its mass?",
"options": ["Only the box, because the mesh does not collide", "Both, because group 1 is inside the default inertiagrouprange (0 to 5)", "Only the mesh, because it is more detailed", "Neither: MuJoCo requires <inertial>"],
"answer": 1,
"explain": "<p>Collision flags do not affect inertia. Geoms contribute to the body's inertia when their group lies in <code>compiler/inertiagrouprange</code>, \"0 5\" by default. The lesson's script measures it: a non-colliding 2 kg geom in group 3 adds its mass until the range is narrowed to \"0 2\".</p>"},
{"kind": "predict", "q": "The arm rests on its shoulder stop with zero torque, 0.53 mrad past it. You double the mass of link 2, which raises the load on the stop by about 60%. How far past the stop does the shoulder rest now?",
"options": ["The same 0.53 mrad: a limit's softness does not depend on the load", "Farther, but by much less than 60%", "About 60% farther, in proportion to the load", "Exactly at the stop: a heavier arm presses the constraint into its stiff regime"],
"answer": 1,
"explain": "<p>Measured: 0.526 mrad at 0.8 kg and 0.665 mrad at 1.6 kg (+26%), while the limit force rises from 2.31 to 3.73 N m (+61%). How much violation a soft constraint needs to produce a given force depends on the joint's effective inertia as well as on <code>solreflimit</code> and <code>solimplimit</code>, and the heavier link adds inertia about the shoulder too. A ball on the floor is the extreme case: load and inertia both scale with its mass, and its penetration does not change at all (Lesson 0.1). Level 9 derives the relation.</p>",
"sim": {"model": "arm2", "height": 200, "camera": {"azimuth": -90, "elevation": 0, "distance": 2.3, "target": [0.15, 0, 1.0]},
"setup": "data.qpos[0] = -1.2; data.qpos[1] = -0.3708",
"controls": [{"label": "link 2 mass", "unit": "kg", "apply": "model.body_mass[2] = value; mj.mj_setConst(model, data)", "min": 0.8, "max": 3.2, "step": 0.8, "value": 0.8, "digits": 1, "reset": true}],
"readouts": [{"label": "past the stop (mrad)", "expr": "1000 * (-1.2 - data.qpos[0])", "digits": 3}, {"label": "limit force (N m)", "expr": "data.qfrc_constraint[0]", "digits": 3}]}}
]}
Lesson 5.2 scales the same checks up to the course’s 7-DOF arm, where two new questions appear: which poses are singular, and why the model needs armature to be stable at all.