Core concept

A PD controller knows nothing about the robot: it reacts to errors after they appear. Model-based control uses the equations of motion of Level 7 to supply the torque the motion needs before an error appears, and leaves feedback to correct what the model gets wrong. Three ways of doing it, in order of how much they trust the model:

The question this lesson measures is what each one buys when the model is right, and what each one costs when it is wrong.

[!established] The ingredients in MuJoCo At any state, mj_forward provides qfrc_bias (gravity, Coriolis and centrifugal) and the mass matrix through mj_fullM; mj_inverse provides the whole inverse dynamics, including passive forces (Lesson 7.2). A controller model is just another MjModel: edit its masses and call mj_setConst to give a controller a deliberately wrong model while the simulated robot keeps the true one.

Visual intuition

The lab runs one 4 s sinusoidal trajectory on arm7 with the controller of your choice; the plot shows the largest joint error.

  1. Run PD. The error stays in the tens of milliradians: the feedback is fighting gravity and the arm’s inertia at every instant.
  2. Run PD + inverse-dynamics feedforward, then computed torque, with the model scale at 1. The error falls by two orders of magnitude, to a fraction of a milliradian.
  3. Set the model scale to 0.5, the controller believing every link weighs half as much, and run both again. Feedforward degrades to about 11 mrad RMS; computed torque to about 15 mrad, worse than feedforward although it was the better of the two with the right model.

```lab pd {“dock”: true, “title”: “Four controllers, one trajectory”, “height”: 280}


## Mathematics

### What each controller leaves to the feedback

Write the true dynamics as $M\ddot q + c = \tau$ (with damping folded into $c$) and the controller's model as $\hat M$, $\hat c$. With the PD part $u = K_p e + K_d \dot e$ and $e = q^\star - q$:

- **PD**: $M\ddot q + c = u$. The feedback must produce all of $M\ddot q^\star + c$, which it can only do by keeping an error.
- **Gravity compensation**: $\tau = u + \hat c(q, \dot q)$. The error must still produce the inertial torque $M\ddot q^\star$.
- **Feedforward**: $\tau = u + \hat M(q^\star)\ddot q^\star + \hat c(q^\star, \dot q^\star)$. Near the plan, the error dynamics are $M\ddot e + K_d\dot e + K_p e \approx (M - \hat M)\ddot q^\star + (c - \hat c)$: the model error enters as a disturbance, and the feedback gains are untouched.
- **Computed torque**: $\tau = \hat M(\ddot q^\star + K_pe + K_d\dot e) + \hat c$. With an exact model, $\ddot e + K_d\dot e + K_pe = 0$ for every joint. With $\hat M = sM$ (all link masses scaled by $s$, ignoring armature), the closed loop becomes $\ddot e + sK_d\dot e + sK_pe = (1-s)\ddot q^\star + \ldots$: the model error scales the feedback gains as well as adding a disturbance.

> [!derivation] Why computed torque suffers more from an underestimated mass
> With $s = 0.5$, computed torque loses both ways: half the needed feedforward and half the intended stiffness, so its effective bandwidth drops from 20 to about 14 rad/s while the disturbance it must reject doubles. Feedforward keeps its full PD gains and only loses part of the feedforward. With $s = 1.5$, computed torque is over-stiff instead of under-stiff, and the extra gain partly offsets the disturbance: output shows 5.6 mrad against 15.3 for $s = 0.5$, an asymmetry the linear argument predicts.

### Fair comparison

All four controllers use the same feedback bandwidth: PD gains $k_{p,i} = M_{ii}\,\omega_n^2$ and $k_{d,i} = 2\zeta M_{ii}\omega_n$ from each joint's inertia at home, and unit-mass gains $\omega_n^2$, $2\zeta\omega_n$ inside computed torque, with $\omega_n$ = 20 rad/s and $\zeta$ = 1. Comparing controllers at different gains would measure the gains, not the model.

## Implementation

```io
INPUT: arm7.xml; controller models with link masses and inertias scaled by 0.5, 1 and 1.5
PROCESS: track a 4 s sinusoid with PD, PD + gravity compensation, PD + inverse-dynamics feedforward and computed torque; report RMS and peak joint error after the first second and time spent saturated
OUTPUT: a table

```python file=examples/l8_2_model_based.py “"”Lesson 8.2: gravity compensation, feedforward and computed torque on the 7-DOF arm.

INPUT arm7.xml (torque motors with limits); a controller model whose link masses and inertias are scaled by s (s = 1 is the true model), sharing the true armature PROCESS track q(t) = home + A sin(2 pi f t) for 4 s with four controllers that use the same feedback bandwidth: PD tau = Kp e + Kd edot PD + gravity … + qfrc_bias of the controller model (current state) PD + feedforward … + inverse dynamics of the plan, M(q) qdd* + c(q, qd) + b qd* computed torque tau = M(q) (qdd* + wn^2 e + 2 wn edot) + c(q, qdot) + b qdot for s = 1, 0.5 and 1.5, and report RMS and peak joint error after the first second OUTPUT a table of tracking errors and torque saturation

Run: python examples/l8_2_model_based.py “””

import mujoco import numpy as np

from mjcourse import model_path

WN, ZETA = 20.0, 1.0 # feedback bandwidth (rad/s) and damping ratio AMP = np.array([0.3, 0.2, 0.3, 0.3, 0.4, 0.3, 0.5]) # rad FREQ, DURATION, SKIP = 0.5, 4.0, 1.0 # Hz, s, s (error statistics start after SKIP)

def controller_model(scale: float) -> tuple[mujoco.MjModel, mujoco.MjData]: “"”arm7 with every link’s mass and inertia scaled; armature and damping are the motors’ and stay.””” model = mujoco.MjModel.from_xml_path(str(model_path(“arm7”))) model.body_mass[1:] *= scale model.body_inertia[1:] *= scale data = mujoco.MjData(model) mujoco.mj_setConst(model, data) return model, data

def plan(home: np.ndarray, t: float): w = 2 * np.pi * FREQ return home + AMP * np.sin(w * t), AMP * w * np.cos(w * t), -AMP * w**2 * np.sin(w * t)

def full_mass(model, data) -> np.ndarray: m = np.zeros((model.nv, model.nv)) mujoco.mj_fullM(model, data, m) return m

def home_inertia(home: np.ndarray) -> np.ndarray: “"”Diagonal of the true mass matrix at home: each joint’s inertia with the others held.””” model, data = controller_model(1.0) data.qpos[:] = home mujoco.mj_forward(model, data) return np.diag(full_mass(model, data))

def track(kind: str, scale: float) -> tuple[float, float, float]: model = mujoco.MjModel.from_xml_path(str(model_path(“arm7”))) data = mujoco.MjData(model) home = model.key_qpos[model.key(“home”).id].copy() cmodel, cdata = controller_model(scale) m_home = home_inertia(home) kp, kd = m_home * WN2, 2 * ZETA * m_home * WN # per-joint gains from the home inertia damping = model.dof_damping data.qpos[:] = home data.qvel[:] = plan(home, 0.0)[1] limit = model.actuator_ctrlrange[:, 1] errors, saturated, h = [], 0, model.opt.timestep for k in range(round(DURATION / h)): t = k * h q_d, qd_d, qdd_d = plan(home, t) e, edot = q_d - data.qpos, qd_d - data.qvel if kind == “PD”: tau = kp * e + kd * edot elif kind == “PD + gravity”: cdata.qpos[:], cdata.qvel[:] = data.qpos, data.qvel mujoco.mj_forward(cmodel, cdata) tau = kp * e + kd * edot + cdata.qfrc_bias elif kind == “PD + feedforward”: cdata.qpos[:], cdata.qvel[:] = q_d, qd_d # the model evaluated along the plan mujoco.mj_forward(cmodel, cdata) tau = kp * e + kd * edot + full_mass(cmodel, cdata) @ qdd_d + cdata.qfrc_bias + damping * qd_d else: # computed torque cdata.qpos[:], cdata.qvel[:] = data.qpos, data.qvel mujoco.mj_forward(cmodel, cdata) v = qdd_d + WN2 * e + 2 * ZETA * WN * edot tau = full_mass(cmodel, cdata) @ v + cdata.qfrc_bias + damping * data.qvel saturated += int(np.any(np.abs(tau) > limit)) data.ctrl[:] = tau mujoco.mj_step(model, data) if t >= SKIP: errors.append(plan(home, t + h)[0] - data.qpos) err = np.array(errors) return float(np.sqrt(np.mean(err**2))), float(np.abs(err).max()), saturated * h

if name == “main”: print(f”track home + A sin(2 pi {FREQ} t), A = {AMP.tolist()} rad, for {DURATION} s; feedback bandwidth “ f”{WN} rad/s, damping ratio {ZETA}; errors after the first {SKIP} s”) print(f” {‘controller’:<18}{‘model scale’:>12}{‘RMS error (mrad)’:>18}{‘peak error (mrad)’:>19}{‘time saturated (s)’:>20}”) for kind in (“PD”, “PD + gravity”, “PD + feedforward”, “computed torque”): for scale in ((1.0,) if kind == “PD” else (1.0, 0.5, 1.5)): rms, peak, sat = track(kind, scale) label = “-“ if kind == “PD” else f”{scale:g}” print(f” {kind:<18}{label:>12}{1000 * rms:>18.2f}{1000 * peak:>19.2f}{sat:>20.3f}”)

Output:

```text
track home + A sin(2 pi 0.5 t), A = [0.3, 0.2, 0.3, 0.3, 0.4, 0.3, 0.5] rad, for 4.0 s; feedback bandwidth 20.0 rad/s, damping ratio 1.0; errors after the first 1.0 s
  controller         model scale  RMS error (mrad)  peak error (mrad)  time saturated (s)
  PD                           -             26.11              50.62               0.000
  PD + gravity                 1             15.88              38.84               0.000
  PD + gravity               0.5             18.10              38.84               0.000
  PD + gravity               1.5             21.72              43.44               0.000
  PD + feedforward             1              0.20               0.35               0.000
  PD + feedforward           0.5             11.18              25.06               0.000
  PD + feedforward           1.5             11.18              24.71               0.000
  computed torque              1              0.17               0.29               0.000
  computed torque            0.5             15.32              49.84               0.000
  computed torque            1.5              5.59              16.65               0.000

Reading the output:

[!recommendation] Choosing between them Feedforward along the plan is the robust default when a trajectory is known in advance: it gets almost all of computed torque’s accuracy when the model is right and keeps the feedback you tuned when it is wrong. Computed torque is the right tool when the trajectory is not known ahead (reactive or task-space control) and the model is trustworthy; if mass is uncertain, err towards overestimating it.

Debugging

Tracking is good on the bench and poor with a payload. The payload changes $M$ and $c$; a model-based controller that does not know about it behaves like the $s \ne 1$ rows. Add the payload to the controller model, or identify it (Level 18).

Computed torque oscillates when the model is changed. The model error also changes its feedback gains (Mathematics). Check the effective stiffness, $\hat M M^{-1}K_p$, not just $K_p$.

Gravity compensation drags a moving arm. qfrc_bias includes Coriolis and centrifugal terms: correct for full compensation of the current state, but if the state estimate is noisy, so is the compensation. Filter velocities or compensate gravity alone (Lesson 7.1, Debugging).

Feedforward helps at slow speeds and hurts at high ones. The plan’s acceleration is not achievable within the torque limits, so the feedforward saturates. Check $ \tau $ against ctrlrange along the plan before running it.

Exercise

Repeat the comparison with the trajectory’s frequency doubled to 1 Hz. Predict how each controller’s error scales with frequency before running (the inertial torque grows with $\omega^2$), then measure.

Challenge

Give the controller model a 1 kg point mass at the tool that the real arm does not carry (or the reverse), and find which controller degrades most. Then add an integral term to the feedforward controller and show how much of the payload error it recovers in 4 s, without windup.

Research connection

[!research] Residual learning on top of a model Learned controllers for real robots often take this lesson’s best structure and learn only the residual: a model-based feedforward or computed-torque term, plus a learned correction for what the model misses (friction, payload, unmodelled actuator dynamics). The table above suggests why the split matters: when the model is right, feedforward leaves almost nothing to learn; when it is wrong in a structured way (a scale on the masses), the residual has a simple form that a correction can learn from little data (inference).

{"id": "8.2-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "With an exact model, which controller turns every joint of the arm into an independent unit mass under PD?",
   "options": ["PD + gravity compensation", "PD + inverse-dynamics feedforward", "Computed torque", "PD"],
   "answer": 2,
   "explain": "<p>Computed torque cancels $M$ and $c$ at the current state, leaving $\\ddot e + K_d\\dot e + K_pe = 0$ per joint.</p>"},
  {"kind": "mcq", "q": "The controller's model has every link's mass halved. Why does computed torque track worse than PD + feedforward?",
   "options": ["It saturates", "The wrong model also halves its effective feedback gains", "It ignores gravity", "It uses the plan instead of the measured state"],
   "answer": 1,
   "explain": "<p>Computed torque multiplies its PD term by $\\hat M$. A too-light model makes the effective stiffness and damping smaller, while feedforward keeps the PD gains it was tuned with.</p>"},
  {"kind": "numeric", "q": "A joint has inertia 0.47 kg m² at home. What PD stiffness kp (N m/rad) gives the 20 rad/s bandwidth used here?",
   "answer": 188, "tol": 0.5, "unit": "N m/rad",
   "explain": "<p>$k_p = M_{ii}\\omega_n^2 = 0.47 \\times 400$ = 188 N m/rad.</p>"},
  {"kind": "mcq", "q": "Gravity compensation removed about 40% of PD's tracking error on the sinusoid, not 100%. What causes the rest?",
   "options": ["Sensor noise", "The inertial torque needed to accelerate the arm along the plan", "Torque limits", "The integrator"],
   "answer": 1,
   "explain": "<p>The plan accelerates the joints; $M\\ddot q^\\star$ must still come from the feedback error. Feedforward supplies it.</p>"}
]}

Next

Lesson 8.3 moves the target from joints to the tool: Jacobian-transpose control, resolved rates, operational-space control with null-space posture, and impedance control in contact.