Core concept

Joint-space control asks each joint to follow its own target. Many tasks are naturally written for the tool instead: follow this line, press with 10 N, keep the cup upright. Task-space control turns a tool-level objective into joint torques, and it comes in four classic forms:

A 7-DOF arm controlling a 3-D tool position has four directions of motion left over. Without a null-space term they are uncontrolled, and the arm drifts through them while the tool stays on task.

[!established] What MuJoCo provides Jacobians and their time derivative (mj_jacSite, mj_jacDot), the mass matrix (mj_fullM) and the bias forces (qfrc_bias) for the current state: every ingredient of the controllers below. All are valid only after mj_forward for the current qpos and qvel; after mj_step, mjData’s kinematic quantities still describe the state before the step, so a controller should call mj_forward first.

Visual intuition

The lab tracks a circle of radius 10 cm at 0.5 Hz in a vertical plane through the home position (Checkpoint 8’s task), and plots the tool’s position error and the elbow’s height.

  1. Run operational space with the posture term on: an error of a few hundredths of a millimetre, and the elbow stays near its home height of 0.72 m.
  2. Turn the posture term off. The tool still follows the circle within a couple of millimetres, but the elbow sinks towards 0.23 m: the arm folds through its redundant directions while nothing tells it not to.
  3. Run Jacobian transpose: errors of several millimetres, and the elbow sinks too.
  4. Back to operational space with the posture term, set the model scale to 0.5 and run: the error grows almost two hundredfold, to about 12 mm. That is Checkpoint 8’s model-mismatch question.

```lab osc {“dock”: true, “title”: “Tracking a circle in task space”, “height”: 300}


## Mathematics

### Jacobian transpose

A force $F$ at the tool does work $F^\top\delta x = F^\top J\delta q$, so the joint torques that produce it are $\tau = J^\top F$ (virtual work). With $F = K_p e + K_d\dot e$ and the bias compensated, the tool behaves like a spring-damper, but with the arm's task-space inertia $\Lambda(q)$ as its mass. That inertia differs between directions and poses, so a single $K_p$ gives a stiff, fast response in one direction and a soft, slow one in another.

### Operational space

From $\dot x = J\dot q$, the tool acceleration is $\ddot x = J\ddot q + \dot J\dot q = JM^{-1}(\tau - c) + \dot J\dot q$. Choose

$$\tau = J^\top F + c + N^\top\tau_0, \qquad F = \Lambda\left(\ddot x^\star + K_p e + K_d\dot e - \dot J\dot q\right), \qquad \Lambda = (JM^{-1}J^\top)^{-1}.$$

> [!derivation] Why the tool becomes a unit mass
> Substituting, $\ddot x = JM^{-1}J^\top F + JM^{-1}N^\top\tau_0 + \dot J\dot q = \Lambda^{-1}F + JM^{-1}N^\top\tau_0 + \dot J\dot q$. With the dynamically consistent null-space projector below, $JM^{-1}N^\top = 0$, so $\ddot x = \ddot x^\star + K_p e + K_d\dot e$, which is $\ddot e + K_d\dot e + K_p e = 0$ in every direction and every pose: one bandwidth, $\omega_n = \sqrt{K_p}$, chosen once.

### The null space

With the dynamically consistent inverse $\bar J = M^{-1}J^\top\Lambda$, the projector $N^\top = I - J^\top\bar J^\top$ passes only the joint torques that produce no tool acceleration. A posture torque $\tau_0 = k(q_\text{home} - q) - d\dot q$ projected this way pulls the elbow back without disturbing the circle. Without it, the posture is governed by nothing but gravity compensation and the coupling of the task, and in output (1) the elbow sinks by half a metre.

### Model error in operational space

If the controller's model has every link's mass scaled by $s$, then $\hat\Lambda \approx s\Lambda$ and $\hat c \approx sc$ (ignoring armature). The commanded unit-mass behaviour becomes a mass of $1/s$, the feedforward is scaled by $s$, and a fraction $1 - s$ of gravity is left to the feedback. With $s = 0.5$ the effective bandwidth drops and the gravity error must be held by the spring; with $s = 1.5$ the controller is stiffer and over-compensates instead. The output shows the same asymmetry as computed torque in Lesson 8.2: 12 mm for $s = 0.5$, 4.3 mm for $s = 1.5$.

### Impedance in contact

Against a stiff surface, the tool cannot reach a target $x^\star$ placed behind it. At rest, $F = K(x^\star - x)$ is balanced by the contact force, so the contact force is commanded by the offset: 2 cm behind the surface with $K$ = 500 N/m gives 10 N. A 3-D impedance leaves the tool's orientation free, and the tool tilts and lands on its rim; a 6-D impedance adds a rotational spring and keeps the face flat. Output (2) confirms both: with orientation control the contact force equals $K(z - z^\star)$ to 0.1%, with four contact points and 0.02° of tilt; without it, the flange tilts 2°, touches at one point, and its centre hangs 1.4 mm above the table.

## Implementation

The controllers are in `mjcourse/control.py`, which now holds the full operational-space law used here:

```python file=src/mjcourse/control.py
"""Controllers written out in NumPy on top of MuJoCo's dynamics quantities.

Every controller is a plain function of (model, data, ...) returning joint torques
(nv,) or actuator controls (nu,), so it can be called inside any stepping loop:

    data.ctrl[:] = joint_pd_gravity(model, data, q_des, kp, kd)
    mujoco.mj_step(model, data)

They assume torque actuators (`motor`, gear 1) mapped one-to-one onto the first
nu degrees of freedom, as in arm2.xml and arm7.xml. Level 8 derives each one.
"""

from __future__ import annotations

from dataclasses import dataclass

import mujoco
import numpy as np

from mjcourse import spatial
from mjcourse.kinematics import site_jacobian


@dataclass(frozen=True)
class Gains:
    kp: np.ndarray            # N m/rad per joint
    kd: np.ndarray            # N m s/rad per joint

    @staticmethod
    def critical(kp: np.ndarray, inertia: np.ndarray) -> "Gains":
        """Damping for a critically damped response of each joint treated alone: kd = 2 sqrt(kp I)."""
        return Gains(np.asarray(kp, float), 2.0 * np.sqrt(np.asarray(kp, float) * np.asarray(inertia, float)))


def clip_to_ctrlrange(model: mujoco.MjModel, u: np.ndarray) -> np.ndarray:
    lo, hi = model.actuator_ctrlrange[:, 0], model.actuator_ctrlrange[:, 1]
    limited = model.actuator_ctrllimited.astype(bool)
    return np.where(limited, np.clip(u, lo, hi), u)


def joint_pd(model: mujoco.MjModel, data: mujoco.MjData, q_des: np.ndarray, kp, kd,
             qd_des: np.ndarray | None = None) -> np.ndarray:
    """tau = Kp (q_des - q) - Kd (qdot - qdot_des), no model knowledge."""
    n = model.nu
    qd_des = np.zeros(n) if qd_des is None else qd_des
    tau = kp * (q_des - data.qpos[:n]) + kd * (qd_des - data.qvel[:n])
    return clip_to_ctrlrange(model, tau)


def joint_pd_gravity(model: mujoco.MjModel, data: mujoco.MjData, q_des: np.ndarray, kp, kd,
                     qd_des: np.ndarray | None = None) -> np.ndarray:
    """PD plus compensation of bias forces (gravity, Coriolis, centrifugal) from data.qfrc_bias.

    qfrc_bias is computed by mj_forward / mj_step's velocity stage for the current state.
    Passive forces (joint damping, springs) are not compensated.
    """
    n = model.nu
    qd_des = np.zeros(n) if qd_des is None else qd_des
    tau = kp * (q_des - data.qpos[:n]) + kd * (qd_des - data.qvel[:n]) + data.qfrc_bias[:n]
    return clip_to_ctrlrange(model, tau)


def computed_torque(model: mujoco.MjModel, data: mujoco.MjData, q_des: np.ndarray, qd_des: np.ndarray,
                    qdd_des: np.ndarray, kp, kd) -> np.ndarray:
    """tau = M(q) (qdd_des + Kp e + Kd edot) + c(q, qdot): feedback linearization in joint space."""
    n = model.nu
    m_full = np.zeros((model.nv, model.nv))
    mujoco.mj_fullM(model, data, m_full)
    e, edot = q_des - data.qpos[:n], qd_des - data.qvel[:n]
    v = qdd_des + kp * e + kd * edot
    tau = m_full[:n, :n] @ v + data.qfrc_bias[:n]
    return clip_to_ctrlrange(model, tau)


def operational_space(model: mujoco.MjModel, data: mujoco.MjData, site: str, x_des: np.ndarray,
                      xd_des: np.ndarray, kp: float, kd: float, xdd_des: np.ndarray | None = None,
                      q_rest: np.ndarray | None = None, k_null: float = 0.0,
                      dyn: tuple[mujoco.MjModel, mujoco.MjData] | None = None) -> np.ndarray:
    """Position-only operational-space control of a site, with null-space posture control.

    F = Lambda (xdd_des + kp e + kd edot - Jdot qdot),  Lambda = (J M^-1 J^T)^-1,
    tau = J^T F + c + b qdot + N^T (k_null (q_rest - q) - 2 sqrt(k_null) qdot),
    N^T = I - J^T Jbar^T with the dynamically consistent inverse Jbar = M^-1 J^T Lambda.
    kp and kd act on a unit mass (Lambda scales them), so kp = wn^2 and kd = 2 wn give
    bandwidth wn. Kinematics (J, Jdot, x) come from `data`, which must be current
    (call mj_forward first). M and c come from `dyn` = (controller model, its data) if
    given, for model-mismatch studies, otherwise from the true model. Orientation is
    left free; Lesson 8.3's impedance controller adds it.
    """
    n = model.nu
    sid = model.site(site).id
    jacp, _ = site_jacobian(model, data, sid)
    j = jacp[:, :n]
    jdot = np.zeros((3, model.nv))
    mujoco.mj_jacDot(model, data, jdot, None, data.site_xpos[sid], model.site_bodyid[sid])
    if dyn is None:
        m_full, bias = np.zeros((model.nv, model.nv)), data.qfrc_bias
        mujoco.mj_fullM(model, data, m_full)
    else:
        cmodel, cdata = dyn
        cdata.qpos[:], cdata.qvel[:] = data.qpos, data.qvel
        mujoco.mj_forward(cmodel, cdata)
        m_full, bias = np.zeros((cmodel.nv, cmodel.nv)), cdata.qfrc_bias
        mujoco.mj_fullM(cmodel, cdata, m_full)
    m_inv = np.linalg.inv(m_full[:n, :n])
    lam = np.linalg.inv(j @ m_inv @ j.T)
    xdd = np.zeros(3) if xdd_des is None else xdd_des
    x, xdot = data.site_xpos[sid], j @ data.qvel[:n]
    force = lam @ (xdd + kp * (x_des - x) + kd * (xd_des - xdot) - jdot[:, :n] @ data.qvel[:n])
    tau = j.T @ force + bias[:n] + model.dof_damping[:n] * data.qvel[:n]
    if k_null > 0 and q_rest is not None:
        j_bar = m_inv @ j.T @ lam                       # dynamically consistent pseudo-inverse
        null = np.eye(n) - j.T @ j_bar.T
        tau = tau + null @ (k_null * (q_rest - data.qpos[:n]) - 2 * np.sqrt(k_null) * data.qvel[:n])
    return clip_to_ctrlrange(model, tau)


def cartesian_impedance(model: mujoco.MjModel, data: mujoco.MjData, site: str, x_des: np.ndarray,
                        quat_des: np.ndarray, k_pos, k_rot: float, *, n: int = 7,
                        xd_des: np.ndarray | None = None, f_ff: np.ndarray | None = None,
                        mass: float = 2.0, inertia: float = 0.05, q_rest: np.ndarray | None = None,
                        k_null: float = 0.0) -> np.ndarray:
    """Cartesian impedance of a site with gravity compensation and no force sensing.

    wrench = [K (x_des - x) + D (xd_des - xdot) + f_ff ; k_rot e_rot - d_rot omega],
    tau = J^T wrench + c + b qdot + N^T (k_null (q_rest - q) - 2 sqrt(k_null) qdot),
    where e_rot is the rotation vector taking the current orientation to quat_des.
    k_pos is a scalar or a 3-vector of stiffnesses along the world axes (N/m); the
    damping D is critical for an effective `mass` (kg), d_rot for an effective
    `inertia` (kg m^2). Held still against an obstacle, the site pushes with
    K (x_des - x) + f_ff, so stiffness sets contact force, not just tracking.
    The arm's dofs are the first n and its torque actuators the first n; data must
    be current (mj_forward). Returns the n torques, clipped to their ctrlrange.
    """
    sid = model.site(site).id
    jacp, jacr = site_jacobian(model, data, sid)
    jac = np.vstack([jacp[:, :n], jacr[:, :n]])
    qvel = data.qvel[:n]
    k = np.broadcast_to(np.asarray(k_pos, float), (3,))
    d = 2.0 * np.sqrt(k * mass)
    d_rot = 2.0 * np.sqrt(k_rot * inertia)
    x, xdot, omega = data.site_xpos[sid], jacp[:, :n] @ qvel, jacr[:, :n] @ qvel
    cur = spatial.mat_to_quat(data.site_xmat[sid].reshape(3, 3))
    e_rot = spatial.quat_to_rotvec(spatial.quat_mul(quat_des, spatial.quat_conj(cur)))
    xd = np.zeros(3) if xd_des is None else xd_des
    force = k * (x_des - x) + d * (xd - xdot) + (0.0 if f_ff is None else f_ff)
    wrench = np.r_[force, k_rot * e_rot - d_rot * omega]
    tau = jac.T @ wrench + data.qfrc_bias[:n] + model.dof_damping[:n] * qvel
    if k_null > 0 and q_rest is not None:
        m_full = np.zeros((model.nv, model.nv))
        mujoco.mj_fullM(model, data, m_full)
        m_inv = np.linalg.inv(m_full[:n, :n])
        lam = np.linalg.inv(jac @ m_inv @ jac.T)
        null = np.eye(n) - jac.T @ (m_inv @ jac.T @ lam).T
        tau = tau + null @ (k_null * (q_rest[:n] - data.qpos[:n]) - 2 * np.sqrt(k_null) * qvel)
    lo, hi = model.actuator_ctrlrange[:n, 0], model.actuator_ctrlrange[:n, 1]
    return np.clip(tau, lo, hi)

The experiment:

INPUT: arm7.xml; arm7_table.xml
PROCESS: track a 10 cm circle with Jacobian-transpose, resolved-rate and operational-space control, with and without a null-space posture term and with a wrong controller model; press the flange into the table with position-only and 6-D impedance at four stiffnesses
OUTPUT: printed tables

```python file=examples/l8_3_task_space.py “"”Lesson 8.3: task-space control: Jacobian transpose, resolved rates, operational space, impedance.

INPUT arm7.xml; arm7_table.xml (the arm over a table, flange 24 mm above it at home) PROCESS (1) track a circle of radius 10 cm at 0.5 Hz with the ee site (vertical plane through the home position) using four controllers, with and without a null-space posture term, and with the controller’s link masses scaled; report RMS and peak position error after 2 s and the elbow’s height; (2) Cartesian impedance pressing the flange 2 cm “into” the table: the contact force against K times the commanded penetration, for position-only and full 6-D impedance and four stiffnesses OUTPUT printed tables

Run: python examples/l8_3_task_space.py “””

import mujoco import numpy as np

from mjcourse import control, model_path, spatial

RADIUS, FREQ, DURATION, SKIP = 0.10, 0.5, 6.0, 2.0 WN = 30.0 # task-space bandwidth for OSC (rad/s) K_NULL = 20.0 # posture stiffness in the null space (N m/rad)

def controller_model(scale: float): 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 circle(center: np.ndarray, t: float): w = 2 * np.pi * FREQ c, s = np.cos(w * t), np.sin(w * t) return (center + RADIUS * np.array([0.0, c, s]), RADIUS * w * np.array([0.0, -s, c]), -RADIUS * w * w * np.array([0.0, c, s]))

def track(kind: str, null: bool = True, scale: float = 1.0) -> tuple[float, 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() data.qpos[:] = home mujoco.mj_forward(model, data) sid, elbow = model.site(“ee”).id, model.body(“link4”).id center = data.site_xpos[sid] - [0.0, RADIUS, 0.0] # the circle passes through the home position dyn = controller_model(scale) m_home = np.diag(_mass(*controller_model(1.0), home)) kpj, kdj = m_home * 20.02, 2 * m_home * 20.0 # joint gains for the resolved-rate inner loop q_ref, kin = home.copy(), mujoco.MjData(model) # reference posture and its kinematics errors, heights, h = [], [], model.opt.timestep for k in range(round(DURATION / h)): t = k * h mujoco.mj_forward(model, data) # kinematics for the current state x_d, v_d, a_d = circle(center, t) if kind == “operational space”: tau = control.operational_space(model, data, “ee”, x_d, v_d, WN2, 2 * WN, xdd_des=a_d, q_rest=home if null else None, k_null=K_NULL if null else 0.0, dyn=dyn) else: cm, cd = dyn cd.qpos[:], cd.qvel[:] = data.qpos, data.qvel mujoco.mj_forward(cm, cd) jac = np.zeros((3, model.nv)) mujoco.mj_jacSite(model, data, jac, None, sid) e, edot = x_d - data.site_xpos[sid], v_d - jac @ data.qvel if kind == “Jacobian transpose”: # F = Kp e + Kd edot with constant gains tau = jac.T @ (400.0 * e + 40.0 * edot) + cd.qfrc_bias + model.dof_damping * data.qvel else: # resolved rate: differential IK, then joint PD pinv = jac.T @ np.linalg.inv(jac @ jac.T + 1e-4 * np.eye(3)) kin.qpos[:] = q_ref mujoco.mj_kinematics(model, kin) qd_ref = pinv @ (v_d + 20.0 * (x_d - kin.site_xpos[sid])) if null: qd_ref += (np.eye(model.nv) - pinv @ jac) @ (2.0 * (home - q_ref)) q_ref = q_ref + h * qd_ref tau = kpj * (q_ref - data.qpos) + kdj * (qd_ref - data.qvel) + cd.qfrc_bias + model.dof_damping * data.qvel tau = control.clip_to_ctrlrange(model, tau) data.ctrl[:] = tau mujoco.mj_step(model, data) heights.append(data.xpos[elbow][2]) if t >= SKIP: mujoco.mj_kinematics(model, data) errors.append(np.linalg.norm(circle(center, t + h)[0] - data.site_xpos[sid])) err = np.array(errors) return 1000 * float(np.sqrt(np.mean(err**2))), 1000 * float(err.max()), min(heights), max(heights)

def _mass(model, data, qpos): data.qpos[:] = qpos mujoco.mj_forward(model, data) m = np.zeros((model.nv, model.nv)) mujoco.mj_fullM(model, data, m) return m

def press(stiffness: float, rotation: bool, depth: float = 0.02, seconds: float = 4.0): model = mujoco.MjModel.from_xml_path(str(model_path(“arm7table”))) data = mujoco.MjData(model) mujoco.mj_resetDataKeyframe(model, data, model.key(“home”).id) mujoco.mj_forward(model, data) home, sid = data.qpos.copy(), model.site(“flange”).id top = model.geom(“table”).pos[2] + model.geom(“table”).size[2] target = data.site_xpos[sid].copy() target[2] = top - depth # 2 cm below the table surface q_target = spatial.mat_to_quat(data.site_xmat[sid].reshape(3, 3)) damping, k_rot = 2 * np.sqrt(stiffness * 2.0), 30.0 # translational damping for an effective 2 kg d_rot = 2 * np.sqrt(k_rot * 0.05) for _ in range(round(seconds / model.opt.timestep)): mujoco.mj_forward(model, data) jp, jr = np.zeros((3, model.nv)), np.zeros((3, model.nv)) mujoco.mj_jacSite(model, data, jp, jr, sid) wrench = stiffness * (target - data.site_xpos[sid]) - damping * (jp @ data.qvel) jac = jp if rotation: cur = spatial.mat_to_quat(data.site_xmat[sid].reshape(3, 3)) e_rot = spatial.quat_to_rotvec(spatial.quat_mul(q_target, spatial.quat_conj(cur))) wrench = np.r[wrench, k_rot * e_rot - d_rot * (jr @ data.qvel)] jac = np.vstack([jp, jr]) mass = np.zeros((model.nv, model.nv)) mujoco.mj_fullM(model, data, mass) m_inv = np.linalg.inv(mass) lam = np.linalg.inv(jac @ m_inv @ jac.T) null = np.eye(model.nv) - jac.T @ (m_inv @ jac.T @ lam).T tau = jac.T @ wrench + data.qfrc_bias + model.dof_damping * data.qvel tau += null @ (K_NULL * (home - data.qpos) - 2 * np.sqrt(K_NULL) * data.qvel) data.ctrl[:] = tau mujoco.mj_step(model, data) mujoco.mj_forward(model, data) table, flange = model.geom(“table”).id, model.geom(“flange”).id force, n, f = 0.0, 0, np.zeros(6) for i in range(data.ncon): c = data.contact[i] if {c.geom1, c.geom2} == {table, flange}: mujoco.mj_contactForce(model, data, i, f) force += f[0] n += 1 cur = spatial.mat_to_quat(data.site_xmat[sid].reshape(3, 3)) tilt = np.degrees(np.linalg.norm(spatial.quat_to_rotvec(spatial.quat_mul(q_target, spatial.quat_conj(cur))))) commanded = stiffness * (data.site_xpos[sid][2] - target[2]) return force, commanded, 1000 * (top - data.site_xpos[sid][2]), n, tilt

if name == “main”: print(f”(1) circle of radius {100 * RADIUS:.0f} cm at {FREQ} Hz in the y-z plane through home; errors after {SKIP} s”) print(f” {‘controller’:<22}{‘posture term’:>13}{‘model scale’:>12}{‘RMS (mm)’:>10}{‘peak (mm)’:>11}{‘elbow height (m)’:>19}”) cases = [(“Jacobian transpose”, False, 1.0), (“resolved rate”, True, 1.0), (“operational space”, True, 1.0), (“operational space”, False, 1.0), (“operational space”, True, 0.5), (“operational space”, True, 1.5)] for kind, null, scale in cases: rms, peak, lo, hi = track(kind, null, scale) print(f” {kind:<22}{(‘on’ if null else ‘off’):>13}{scale:>12g}{rms:>10.3f}{peak:>11.3f}{lo:>11.3f} to {hi:.3f}”) print(“(2) impedance: flange commanded 20 mm below the table surface, held 4 s”) print(f” {‘stiffness’:>10}{‘controller’:>14}{‘contact force’:>15}{‘K x (z - z*)’:>14}{‘into table’:>12}{‘contacts’:>10}{‘tilt’:>10}”) for k, rot in ((200, False), (200, True), (500, True), (1000, True), (3000, True)): force, commanded, depth, n, tilt = press(k, rot) print(f” {k:>6} N/m{(‘6-D’ if rot else ‘position’):>14}{force:>13.3f} N{commanded:>12.3f} N{depth:>9.3f} mm{n:>10}{tilt:>8.3f} deg”)

Output:

```text
(1) circle of radius 10 cm at 0.5 Hz in the y-z plane through home; errors after 2.0 s
  controller             posture term model scale  RMS (mm)  peak (mm)   elbow height (m)
  Jacobian transpose              off           1     8.009     18.420      0.228 to 0.733
  resolved rate                    on           1     4.452      4.940      0.668 to 0.744
  operational space                on           1     0.064      0.067      0.669 to 0.743
  operational space               off           1     1.613     11.595      0.229 to 0.732
  operational space                on         0.5    11.990     12.620      0.657 to 0.740
  operational space                on         1.5     4.340      4.540      0.675 to 0.746
(2) impedance: flange commanded 20 mm below the table surface, held 4 s
   stiffness    controller  contact force  K x (z - z*)  into table  contacts      tilt
     200 N/m      position        4.397 N       4.278 N   -1.391 mm         1   2.048 deg
     200 N/m           6-D        4.000 N       3.998 N    0.009 mm         4   0.002 deg
     500 N/m           6-D        9.991 N       9.989 N    0.022 mm         4   0.005 deg
    1000 N/m           6-D       19.959 N      19.956 N    0.044 mm         4   0.008 deg
    3000 N/m           6-D       59.623 N      59.610 N    0.130 mm         4   0.021 deg

Reading the output:

Debugging

The tool tracks but the arm slowly folds or twists. A redundant arm with no null-space term. Add a posture torque projected with $N^\top = I - J^\top\bar J^\top$; check the elbow height or the distance to the joint limits over time.

Operational space control is stiff in one direction and sloppy in another. $\Lambda$ was left out (that is Jacobian transpose) or computed from a stale mass matrix. Compute $M$, $J$ and $\dot J$ after mj_forward for the current state.

The controller goes unstable near a singular pose. $\Lambda = (JM^{-1}J^\top)^{-1}$ grows without bound as $J$ loses rank. Damp the inversion (add a small multiple of the identity before inverting) and keep the task away from singular poses (Lesson 6.2).

An impedance controller presses with the wrong force. The tool is tilting and touching on an edge, so the contact force is not along the controlled axis (output 2, position-only row). Control the orientation as well, or measure the force where it actually acts.

Exercise

Add orientation control to the circle task (keep the tool pointing down): extend $J$ to $6 \times 7$, add a rotational error term as in the impedance controller, and compare the circle’s RMS error and the elbow’s motion with the position-only case. How many redundant directions are left for the posture term?

Challenge

Checkpoint 8, completely: implement operational-space control for the circle from scratch (do not call mjcourse.control), plot the error and the elbow height, then halve the modelled link masses and report the change. Finally, add an integral term on the task-space error and find how much of the 12 mm it recovers without destabilizing the loop.

Research connection

[!research] Action spaces and controllers for learned policies Many manipulation policies output tool poses or tool deltas and leave the conversion to a task-space controller like these. The controller’s character, stiff and decoupled (operational space) or compliant and pose-dependent (Jacobian transpose, impedance), shapes how the same policy output turns into motion and contact force. Variable-impedance policies make the stiffness itself an output. This lesson’s numbers suggest one reason contact-rich policies prefer impedance-style action spaces: they make the contact force a predictable function of the commanded offset (inference).

{"id": "8.3-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "Why does operational-space control give the same response in every direction, while Jacobian-transpose control does not?",
   "options": ["It uses a smaller timestep", "It scales the commanded force by the task-space inertia Λ, so the tool behaves like a unit mass", "It ignores gravity", "It uses joint-space gains"],
   "answer": 1,
   "explain": "<p>With $F = \\Lambda(\\ddot x^\\star + K_pe + K_d\\dot e - \\dot J\\dot q)$ and the bias compensated, $\\ddot e + K_d\\dot e + K_pe = 0$ regardless of $\\Lambda(q)$.</p>"},
  {"kind": "numeric", "q": "An impedance controller with K = 800 N/m commands a target 15 mm below a rigid table surface. What steady contact force (N) do you expect if the tool lands flat?",
   "answer": 12, "tol": 0.05, "unit": "N",
   "explain": "<p>$F = K\\delta = 800 \\times 0.015$ = 12 N; output (2) matches $K(z - z^\\star)$ to 0.1% with orientation control.</p>"},
  {"kind": "mcq", "q": "With the posture term off, operational-space control still tracks the circle but the elbow sinks by half a metre. Why?",
   "options": ["The circle is out of reach", "The arm has redundant directions of motion that the task leaves uncontrolled", "Gravity compensation is switched off", "The controller saturates"],
   "answer": 1,
   "explain": "<p>Seven joints, three task coordinates: four directions of self-motion that only a null-space term controls.</p>"},
  {"kind": "mcq", "q": "Which projector lets posture torques act without disturbing the tool's acceleration?",
   "options": ["I − J⁺J with the Moore-Penrose inverse", "I − Jᵀ J̄ᵀ with J̄ = M⁻¹JᵀΛ", "Jᵀ J", "The identity"],
   "answer": 1,
   "explain": "<p>The dynamically consistent projector satisfies $JM^{-1}N^\\top = 0$, so projected torques produce no tool acceleration. The kinematic projector of Lesson 6.3 removes velocity in the task directions, which is the right notion for IK but not for torques.</p>"}
]}

Next

Level 9 turns to contact and friction: the soft-constraint model behind every number in output (2), the friction cones, and how to make contact behave.