Kiru Lab / Embodied Control: Kinematics and the Inverse Jacobian / Inverse Kinematics
Redundancy and the Null Space
With more joints than task dimensions there are infinitely many answers. Choose among them on purpose.
Concept · about 35 minutes
A seven-joint arm positioning a hand in six-dimensional pose space has one degree of redundancy: a continuum of joint configurations produce the same hand pose. Your elbow can swing while your hand stays put — try it. That freedom is not a nuisance; it is capacity for a second objective.
The projector `(I - J^+ J)` annihilates any component of `z` that would disturb the task. So you can pick `z` to be the gradient of any secondary objective and pursue it for free — stay away from joint limits, maximize manipulability so you do not drift toward a singularity, avoid an obstacle, or minimize energy.
import numpy as np
def redundant_step(J, error, secondary_gradient):
J_pinv = np.linalg.pinv(J)
null_projector = np.eye(J.shape[1]) - J_pinv @ J
return J_pinv @ error + null_projector @ secondary_gradient
def away_from_limits(q, q_min, q_max):
"""A secondary objective: push each joint toward the middle of its range."""
middle = (q_min + q_max) / 2
return -(q - middle) / (q_max - q_min) ** 2Human motor control faces exactly this problem, and Bernstein named it in the 1960s: the body has vastly more degrees of freedom than any task requires, yet movement is stereotyped and smooth rather than arbitrary. The nervous system is choosing within a null space too, and the criterion it uses — smoothness, effort, variance minimization — is still argued about. The engineering solution and the biological question are the same question.
There is also a machine-learning mirror. An overparameterized network has many parameter settings that fit the training data equally well; which one optimization lands on is determined by implicit biases in the algorithm rather than by the loss. Redundancy resolved by a secondary criterion is a pattern you have now seen in three places.
Hold on to
- Redundancy means a continuum of solutions, not an ambiguity to eliminate
- Null-space projection pursues a secondary objective without disturbing the task
- Bernstein's degrees-of-freedom problem is the same problem in biology
Work through
Try each one before opening the solution. Getting it wrong first is most of where the learning happens.
-
Build a four-link planar arm (two redundant degrees of freedom) and hold the hand fixed while the elbow moves through the null space.
Hint
With 4 joints and a 2-D task, the null space is 2-dimensional.
Solution
Project a random vector through `(I - J^+ J)` and step along it: the joint angles change visibly while the end-effector position stays fixed to numerical precision. This is self-motion — the arm reconfiguring without doing anything the task can see. It is exactly what your elbow does while your hand stays on a doorknob.
import numpy as np lengths = np.array([1.0, 0.8, 0.6, 0.4]) q = np.array([0.3, 0.5, -0.4, 0.2]) start = forward_kinematics(q, lengths) rng = np.random.default_rng(0) for _ in range(50): J = jacobian_analytic(q, lengths) null = np.eye(4) - np.linalg.pinv(J) @ J q = q + 0.05 * (null @ rng.normal(size=4)) print("joint change:", np.abs(q - np.array([0.3, 0.5, -0.4, 0.2])).max()) print("hand moved by:", np.linalg.norm(forward_kinematics(q, lengths) - start))Check your work
Paste this after your own code. If it runs without raising, you have it.
import numpy as np assert np.linalg.norm(forward_kinematics(q, lengths) - start) < 1e-6 print("ok") -
Add a joint-limit-avoidance secondary objective and show the arm reaches the same targets with a better posture.
Hint
Use the `away_from_limits` gradient as the secondary objective z.
Solution
The arm reaches the same targets — the primary task is untouched, by construction — but finishes with joints nearer the middle of their ranges. Measure it: the maximum normalized distance from range centre drops substantially. This is free capability, paid for entirely out of redundancy you already had.
import numpy as np q_min = np.full(4, -np.pi / 2) q_max = np.full(4, np.pi / 2) def solve(target, q0, use_secondary): q = np.array(q0, dtype=float) for _ in range(300): error = target - forward_kinematics(q, lengths) if np.linalg.norm(error) < 1e-4: break J = jacobian_analytic(q, lengths) z = away_from_limits(q, q_min, q_max) if use_secondary else np.zeros(4) q = q + 0.3 * redundant_step(J, error, z) return q for flag in (False, True): q = solve(np.array([1.5, 0.5]), [0.1, 0.1, 0.1, 0.1], flag) margin = np.abs(q - (q_min + q_max) / 2) / (q_max - q_min) print(f"secondary={flag}: worst limit proximity {margin.max():.3f}")
Sign in to track your progress through the lab.