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 aftermj_forwardfor the currentqposandqvel; aftermj_step,mjData’s kinematic quantities still describe the state before the step, so a controller should callmj_forwardfirst.
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.
```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:
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.
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?
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] 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>"}
]}
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.