roostField / Lab
Curriculum
Phase 02Lesson 3 of 8
80 min
Build a classical oracleWeeks 3–6

Inverse kinematics: analytic, numeric, and constrained

Forward kinematics is a function; your robot needs its inverse thousands of times per minute — and the inverse is not a function at all. Learn when geometry hands you a closed form, how to solve numerically without exploding near singularities, and how to fold joint limits and collision margins into a small QP that runs every control tick.

After this lesson you can
  • Derive the closed-form solution of a planar two-link arm and enumerate its solution branches.
  • Implement a damped-least-squares IK solver with step limiting, joint-limit clamping, and honest non-convergence reporting.
  • Formulate constrained differential IK as a quadratic program with joint position, velocity, and collision-margin constraints.
  • Use the nullspace projector to pursue secondary objectives without disturbing the end-effector task.

Forward kinematics, from two lessons ago, is a clean function: joint angles in, end-effector pose out — a forward pass. But almost nothing your robot does starts from joint angles. Perception hands you a grasp pose in Cartesian space; the pi-zero-style policies of Phase 04 emit end-effector targets; every waypoint from Lesson 7's motion planner lives in SE(3)SE(3). Between all of them and the six joint position commands your WidowX AI's iNerve controller tracks at 500 Hz sits the inverse problem: given a pose, find joint angles that produce it. Unlike the forward pass, the inverse is not a function — a single pose can have zero, two, eight, or infinitely many answers — and the standard way to compute it is an optimization loop running inside a control tick with a hard deadline. That combination, multiplicity plus iterative solving under a latency budget, is this lesson's subject.

Foundations: The Twist, Rotation Error, and Damped Optimization

Before deriving the solver, we must rigorously define the quantities it manipulates. The geometric Jacobian JJ maps joint velocities q˙Rn\dot{\mathbf{q}} \in \mathbb{R}^n to the end-effector twist VR6\mathbf{V} \in \mathbb{R}^6. The twist is a 6-dimensional vector stacking the linear velocity vR3\mathbf{v} \in \mathbb{R}^3 (meters per second) and the angular velocity ωR3\boldsymbol{\omega} \in \mathbb{R}^3 (radians per second): V=[v;ω]\mathbf{V} = [\mathbf{v}; \boldsymbol{\omega}]. This vector lives in the tangent space of the special Euclidean group SE(3)SE(3), representing an infinitesimal rigid-body motion.

A critical gap in many implementations is the definition of orientation error. We cannot subtract rotation matrices directly because SO(3)SO(3) is a non-linear manifold. Instead, we map the rotation error into R3\mathbb{R}^3 using the logarithm map. Given a current orientation RcR_c and a target RtR_t, the relative rotation is Rerr=RtRcR_{err} = R_t R_c^\top. The orientation error vector is erot=log(Rerr)\mathbf{e}_{rot} = \log(R_{err}), which yields an axis-angle vector. This vector represents the rotation required to align RcR_c with RtR_t and is linearizable near the identity, making it suitable for Newton-Raphson methods.

erot=log(RtRc)=θn^,where cosθ=tr(Rerr)12\mathbf{e}_{rot} = \log(R_t R_c^\top) = \theta \hat{\mathbf{n}}, \quad \text{where } \cos\theta = \frac{\text{tr}(R_{err}) - 1}{2}
The rotation logarithm maps a rotation matrix to its axis-angle representation in R^3.

The Damped Least Squares (DLS) method minimizes the objective f(Δq)=JΔqe2+λ2Δq2f(\Delta\mathbf{q}) = \|J\Delta\mathbf{q} - \mathbf{e}\|^2 + \lambda^2 \|\Delta\mathbf{q}\|^2. To find the optimal step Δq\Delta\mathbf{q}, we take the gradient with respect to Δq\Delta\mathbf{q} and set it to zero. The gradient is Δqf=2J(JΔqe)+2λ2Δq\nabla_{\Delta\mathbf{q}} f = 2J^\top(J\Delta\mathbf{q} - \mathbf{e}) + 2\lambda^2 \Delta\mathbf{q}. Setting this to zero and rearranging yields the normal equations: (JJ+λ2I)Δq=Je(J^\top J + \lambda^2 I)\Delta\mathbf{q} = J^\top \mathbf{e}. This derivation confirms that DLS is a regularized linear solve, where λ2\lambda^2 acts as a ridge penalty to stabilize the inverse when JJ is ill-conditioned.

Unit consistency is often overlooked. Position error is in meters, while orientation error is in radians. To make the objective function dimensionally consistent, we scale the rotation rows of the error vector and the Jacobian by a characteristic length ss (e.g., 0.1 m). This ensures that a 1-radian orientation error is weighted equivalently to a 0.1-meter position error, reflecting the physical displacement at the tool tip.

Consider a planar 2-link arm with l1=l2=0.3l_1 = l_2 = 0.3 m. The target is x=0.4x = 0.4 m, y=0.2y = 0.2 m. First, check reachability: r=0.42+0.220.447r = \sqrt{0.4^2 + 0.2^2} \approx 0.447 m. Since l1l2rl1+l2|l_1 - l_2| \le r \le l_1 + l_2, the target is reachable. The elbow angle is cosθ2=r2l12l222l1l20.111\cos\theta_2 = \frac{r^2 - l_1^2 - l_2^2}{2 l_1 l_2} \approx 0.111, so θ2±83.6\theta_2 \approx \pm 83.6^\circ.

θ1=atan2(y,x)atan2(l2sinθ2,l1+l2cosθ2)\theta_1 = \operatorname{atan2}(y, x) - \operatorname{atan2}(l_2 \sin\theta_2, l_1 + l_2 \cos\theta_2)
Shoulder angle calculation for the elbow-up branch.

For the elbow-up branch (θ2>0\theta_2 > 0), θ117.5\theta_1 \approx -17.5^\circ. Now, suppose we start at q=[0,0]\mathbf{q} = [0, 0]^\top. The Jacobian is J=[0.30.60.30]J = \begin{bmatrix} -0.3 & -0.6 \\ 0.3 & 0 \end{bmatrix}. The error is e=[0.4,0.2]\mathbf{e} = [0.4, 0.2]^\top. Using DLS with λ=0.1\lambda = 0.1, we solve (JJ+0.01I)Δq=Je(J^\top J + 0.01 I)\Delta\mathbf{q} = J^\top \mathbf{e}. The unclamped step is Δq[0.52,0.31]\Delta\mathbf{q} \approx [0.52, 0.31]^\top. The norm is Δq0.61\|\Delta\mathbf{q}\| \approx 0.61 rad, which exceeds the step limit of 0.3 rad. We scale the step by 0.3/0.610.490.3/0.61 \approx 0.49, yielding a new configuration qnew[0.25,0.15]\mathbf{q}_{new} \approx [0.25, 0.15]^\top.

QuantityValueUnit
Target Distance r0.447m
Elbow Angle theta283.6deg
Shoulder Angle theta1-17.5deg
Raw Step Norm0.61rad
Scaled Step Norm0.30rad
DLS Step Breakdown
Checkpoint 01

Why do we scale the rotation error by a characteristic length ss in the DLS objective?

Closed form, when the geometry cooperates

Start with the smallest problem that exhibits everything interesting: a planar two-link arm with link lengths l1l_1 and l2l_2 — think of your WidowX AI viewed from the side with the base yaw frozen, roughly l1l20.3l_1 \approx l_2 \approx 0.3 m (illustrative lengths for a 0.77 m-reach arm). Forward kinematics of the tip is:

x=l1cosθ1+l2cos(θ1+θ2),y=l1sinθ1+l2sin(θ1+θ2)x = l_1 \cos\theta_1 + l_2 \cos(\theta_1 + \theta_2), \qquad y = l_1 \sin\theta_1 + l_2 \sin(\theta_1 + \theta_2)

To invert, square and add both equations. The cross terms collapse via the angle-sum identities and θ1\theta_1 vanishes entirely — squared distance from the base depends only on the elbow angle:

r2=x2+y2=l12+l22+2l1l2cosθ2        cosθ2=x2+y2l12l222l1l2r^2 = x^2 + y^2 = l_1^2 + l_2^2 + 2 l_1 l_2 \cos\theta_2 \;\;\Longrightarrow\;\; \cos\theta_2 = \frac{x^2 + y^2 - l_1^2 - l_2^2}{2 l_1 l_2}
The law of cosines in disguise: the base, elbow, and target form a triangle with sides l1, l2, and r.

Two things fall out immediately. Reachability: a solution exists iff cosθ21|\cos\theta_2| \le 1, i.e. l1l2rl1+l2|l_1 - l_2| \le r \le l_1 + l_2 — the workspace is an annulus (a full disk of radius 0.6 m for equal links). Multiplicity: θ2=±arccos()\theta_2 = \pm\arccos(\cdot) gives two branches, the familiar elbow-up and elbow-down configurations. With θ2\theta_2 fixed, θ1\theta_1 follows from the target bearing minus the interior angle of the triangle:

θ1=atan2(y,x)    atan2 ⁣(l2sinθ2,  l1+l2cosθ2)\theta_1 = \operatorname{atan2}(y, x) \;-\; \operatorname{atan2}\!\big(l_2 \sin\theta_2,\; l_1 + l_2 \cos\theta_2\big)

Multiplicity scales with structure. A 6-DOF arm with a spherical wrist — last three joint axes intersecting at one point — has up to 8 discrete solutions (shoulder left/right times elbow up/down times wrist flip); a fully general 6R chain has up to 16. The spherical wrist is what makes industrial arms analytically solvable, via a decomposition you should recognize as separation of concerns: the wrist-center position depends only on the first three joints, and the orientation only on the last three. Compute the wrist center by backing off from the target pose along the tool axis,

pw=ptargetd6Rtargetz^\mathbf{p}_w = \mathbf{p}_{\text{target}} - d_6\, R_{\text{target}}\,\hat{\mathbf{z}}
d6 is the wrist-to-flange offset; the first three joints solve position of p_w (a 2-link-style problem plus a base-yaw rotation), the last three solve the remaining rotation.

Now the practical question for your hardware: does the geometry decompose? Open the URDF in Trossen's ROS 2 description for the WidowX AI and check whether the last three axes truly intersect at a common point — real arms routinely carry millimeter-scale offsets that break the spherical-wrist condition (Pieper's condition, in the textbook framing), and Trossen publishes no closed-form kinematic parameters for this arm. Do not assume a textbook decomposition your URDF cannot certify. Closed-form IK is still worth having where it exists — it costs a microsecond, enumerates every branch where numeric solvers find one, and makes an excellent seed generator — but for your arm, the workhorse is numeric.

Checkpoint 02

A planar 2-link arm has l1 = l2 = 0.3 m. How many IK solutions exist for a target at exactly r = 0.6 m from the base?

Numeric IK: root finding against a rank-deficient Jacobian

Numeric IK treats the problem as root finding. Define a residual e(q)R6\mathbf{e}(\mathbf{q}) \in \mathbb{R}^6 stacking the position error ptargetp(q)\mathbf{p}_{\text{target}} - \mathbf{p}(\mathbf{q}) and an orientation error — the axis-angle vector (rotation log) of RtargetR(q)R_{\text{target}} R(\mathbf{q})^\top. Linearize FK around the current guess with last lesson's geometric Jacobian: since e\mathbf{e} is target-minus-current, a joint step Δq\Delta\mathbf{q} reduces the residual by approximately JΔqJ\Delta\mathbf{q}, and Newton-Raphson picks the step that zeroes the linearization:

J(qk)Δq=e(qk),qk+1=qk+ΔqJ(\mathbf{q}_k)\,\Delta\mathbf{q} = \mathbf{e}(\mathbf{q}_k), \qquad \mathbf{q}_{k+1} = \mathbf{q}_k + \Delta\mathbf{q}
For a 6-DOF arm J is 6x6 and this is a plain linear solve — when J has full rank.

Near a solution this converges quadratically — 3 to 6 iterations from a decent seed, each costing one FK, one Jacobian, and one 6×6 solve (about 2 µs in numpy on your workstation; a full warm solve lands well under a millisecond). The failure mode is the one last lesson set up: as the arm approaches a singularity, σmin(J)0\sigma_{\min}(J) \to 0 and the Newton step J1eJ^{-1}\mathbf{e} explodes along the near-null direction — a 1 mm residual can demand a 3-radian joint step. The fix is to stop asking for an exact linear solve and instead minimize a damped objective:

Δq=argminΔq    JΔqe2  +  λ2Δq2\Delta\mathbf{q}^\star = \arg\min_{\Delta\mathbf{q}} \;\; \lVert J\Delta\mathbf{q} - \mathbf{e} \rVert^2 \;+\; \lambda^2 \lVert \Delta\mathbf{q} \rVert^2

Set the gradient to zero — 2J(JΔqe)+2λ2Δq=02J^\top(J\Delta\mathbf{q} - \mathbf{e}) + 2\lambda^2\Delta\mathbf{q} = 0 — and collect terms to get the damped least squares (DLS) solution, the Levenberg-Marquardt step specialized to this residual. The two forms below are algebraically identical; use the second, because for a 6-DOF task JJJJ^\top is 6×6 regardless of joint count:

Δq=(JJ+λ2I)1Je  =  J(JJ+λ2I)1e\Delta\mathbf{q} = \big(J^\top J + \lambda^2 I\big)^{-1} J^\top \mathbf{e} \;=\; J^\top \big(J J^\top + \lambda^2 I\big)^{-1} \mathbf{e}
In the SVD basis each singular direction gets gain sigma / (sigma^2 + lambda^2) — approximately 1/sigma when sigma is large, but peaking at 1/(2*lambda) instead of diverging as sigma goes to 0.

That SVD reading is the whole story of λ\lambda: it trades tracking accuracy near singularities for a hard bound on step size. With λ=0.1\lambda = 0.1, no direction is amplified by more than 5×, no matter how degenerate the configuration. Typical settings for a WidowX-AI-scale arm: λ[0.05,0.1]\lambda \in [0.05, 0.1], tolerances of 10410^{-4} m and 10310^{-3} rad (0.1 mm and 0.06° — well below the arm's ~1 mm repeatability, so the solver is never your dominant error source). Two more practicalities. Step limiting: cap Δq\lVert\Delta\mathbf{q}\rVert at 0.2–0.3 rad per iteration so the linearization stays valid — the trust-region move. Unit weighting: position error is in meters, orientation error in radians, and stacking them unweighted implicitly says 1 rad matters as much as 1 m. Scale the rotation rows of both e\mathbf{e} and JJ by a characteristic length — 0.1 m, roughly your gripper length — so a radian of orientation error costs the same as the tool-tip displacement it actually causes.

Damping fixes exploding steps but not local minima. DLS is a local method: if the path from seed to solution is blocked by a joint limit, or position and orientation errors pull in opposite directions, the iterate stalls at a nonzero residual. From a random seed expect 20–50 iterations, and on a limits-heavy arm expect a few percent of solves to stall even for reachable targets. The remedy is cheap and unglamorous: detect the stall (error plateau across iterations) and restart from a new random seed inside the limits. Five restarts at under a millisecond each keeps the worst case below 5 ms.

Differential IK: track trajectories in velocity space

Pose IK answers where should the joints be; a trajectory needs how should the joints move at 50–500 Hz. You could re-run pose IK at every waypoint, but that is the wrong tool three times over. First, branch consistency: each solve is an independent optimization that may land on a different branch — elbow-up at waypoint kk, elbow-down at k+1k{+}1 — and interpolating between them swings the whole arm violently. Second, wasted work: adjacent waypoints differ by millimeters, yet each cold solve burns 20+ iterations rediscovering what the last one knew. Third, no velocity continuity: independent position solutions say nothing about smoothness. So stop solving for positions and solve for velocities — differential IK. Command a desired end-effector twist, convert it to joint velocities with one damped solve, integrate:

q˙=Jλ(Vff+Kpe(q)),qt+h=qt+hq˙\dot{\mathbf{q}} = J^{\dagger}_{\lambda}\big(\mathbf{V}_{\text{ff}} + K_p\, \mathbf{e}(\mathbf{q})\big), \qquad \mathbf{q}_{t+h} = \mathbf{q}_t + h\,\dot{\mathbf{q}}
Closed-loop IK: a feedforward twist along the reference plus proportional feedback on pose error, so integration drift is continuously corrected. J-dagger-lambda is the damped pseudoinverse from above.

One 6×nn damped solve per tick — microseconds — so this runs comfortably at 500 Hz on a single CPU core. (Too small for the GPU, whose launch overhead exceeds the solve; batched IK on the RTX card returns in Lesson 6 for grasp-candidate ranking.) Because each tick moves continuously from the previous configuration, the solution cannot jump branches: differential IK converts a discrete, multi-valued problem into a continuous, single-valued flow. Pose IK does not disappear — you still need it for starting and goal configurations in Lesson 7's planner — but everything that moves smoothly should move through velocity space. This layer is also load-bearing for Phase 04: when a VLA policy emits end-effector waypoints, differential IK is what turns them into joint commands, and an unreported stall here is indistinguishable from a bad policy in your eval numbers.

Constraints as first-class citizens: differential IK as a QP

A real arm has joint position limits (on the WidowX AI, joint 1 travels 0–180° and joint 2 only 0–135°), velocity bounds (360°/s on the proximal joints, 540°/s at the wrist), and things it must not hit — including its own table. The lazy approach: compute q˙=JλVd\dot{\mathbf{q}} = J^{\dagger}_{\lambda}\mathbf{V}_d, then clip each joint velocity to its bounds. This is wrong in a specific, instructive way the quiz below probes. The right approach, developed in the MIT manipulation notes' pick-and-place chapter (opens in a new tab), is to make constraints part of the optimization itself. The key enabler: with velocities as decision variables, every constraint of interest is linear, so the problem is a tiny quadratic program. Position limits become velocity bounds through a one-tick lookahead — to guarantee qminq+hq˙qmax\mathbf{q}_{\min} \le \mathbf{q} + h\dot{\mathbf{q}} \le \mathbf{q}_{\max}, bound the velocity by:

max ⁣(q˙min,  qminqh)    q˙    min ⁣(q˙max,  qmaxqh)\max\!\Big(\dot{\mathbf{q}}_{\min},\; \tfrac{\mathbf{q}_{\min} - \mathbf{q}}{h}\Big) \;\le\; \dot{\mathbf{q}} \;\le\; \min\!\Big(\dot{\mathbf{q}}_{\max},\; \tfrac{\mathbf{q}_{\max} - \mathbf{q}}{h}\Big)
As a joint approaches its limit, its allowed velocity toward the limit shrinks to zero — a linear box constraint that tightens automatically.

Collision margins fit the same mold. If d(q)d(\mathbf{q}) is the signed distance between a link and an obstacle, its time derivative is linear in joint velocity, d˙=Jdq˙\dot{d} = J_d\,\dot{\mathbf{q}} for a distance Jacobian JdJ_d — so requiring the margin not to shrink faster than it can afford is one more linear row: Jdq˙γ(ddsafe)/hJ_d\,\dot{\mathbf{q}} \ge -\gamma\,(d - d_{\text{safe}})/h. Assemble everything:

minq˙Jq˙Vd2  +  λ2q˙2s.t.q˙lo(q)    q˙    q˙hi(q)Jdq˙    γ(d(q)dsafe)/h\begin{aligned} \min_{\dot{\mathbf{q}}} \quad & \lVert J\dot{\mathbf{q}} - \mathbf{V}_d \rVert^2 \;+\; \lambda^2 \lVert \dot{\mathbf{q}} \rVert^2 \\ \text{s.t.} \quad & \dot{\mathbf{q}}_{\text{lo}}(\mathbf{q}) \;\le\; \dot{\mathbf{q}} \;\le\; \dot{\mathbf{q}}_{\text{hi}}(\mathbf{q}) \\ & J_d\,\dot{\mathbf{q}} \;\ge\; -\gamma\,\big(d(\mathbf{q}) - d_{\text{safe}}\big)/h \end{aligned}
Constrained differential IK. Unconstrained, the optimum is exactly the DLS step; the constraints bend it toward the best feasible motion instead of an infeasible one.

Feel the scale: 6 decision variables, a dozen box bounds, a handful of distance rows. An off-the-shelf QP solver like OSQP dispatches this in 50–200 µs on one CPU core — inside a 2 ms control tick with an order of magnitude to spare. And the QP does something clamping never can: when the desired twist is infeasible, it returns the closest feasible motion — typically preserving the direction of end-effector motion while scaling its speed.

Checkpoint 03

Your diff-IK loop computes joint velocities with the damped pseudoinverse, then clips each joint's velocity to its bounds element-wise. During a straight-line Cartesian move that saturates the wrist joint, what do you observe at the gripper?

ApproachCost per solveBranchesConstraintsBest for
Analytic (closed form)~1 µs, fixedEnumerates all of themChecked after the factSimple or spherical-wrist geometries; seeding numeric solvers; global reasoning
Numeric pose IK (Newton / DLS)0.1–1 ms warm, ~5 ms with restartsConverges to one nearby branchCrude: clamp between iterationsOne-shot goals: grasp poses, planner endpoints
Differential IK as a QP50–200 µs per tickStays on the current branch by constructionFirst-class linear constraintsContinuous tracking at 100 Hz+ under joint, velocity, and collision limits
The three IK regimes and when to reach for each

Redundancy and the nullspace

When the arm has more joint freedoms than the task demands, the surplus is called redundancy, and the set of joint velocities that produce zero task motion is the nullspace of JJ. You might think a 6-DOF arm has none — six joints, six pose coordinates — but redundancy is a property of the task, not the arm. Command a top-down grasp of a cylinder and the rotation about the vertical approach axis is free: the task is 5-DOF, your WidowX AI has 6, and a one-dimensional family of equally correct configurations appears. The classical tool for spending that freedom is nullspace projection:

q˙=JVd+(IJJ)z,z=k0qw(q)\dot{\mathbf{q}} = J^{\dagger}\mathbf{V}_d + \big(I - J^{\dagger}J\big)\,\mathbf{z}, \qquad \mathbf{z} = k_0\,\nabla_{\mathbf{q}}\, w(\mathbf{q})
Any z passed through the projector (I - J-dagger J) produces zero end-effector motion, because J(I - J-dagger J) = 0. The secondary objective w is commonly distance-from-joint-limits: w(q) = -(1/2n) * sum of ((q_i - q_mid,i) / range_i)^2.

Read the structure: the first term does the task; the second term ascends a secondary objective ww — stay near joint mid-range, maximize last lesson's manipulability measure, lean away from an obstacle — but only along directions the task cannot feel. In the QP the same idea is even simpler: add a small cost ϵq˙q˙posture2\epsilon\lVert\dot{\mathbf{q}} - \dot{\mathbf{q}}_{\text{posture}}\rVert^2 and the solver spends the leftover freedom on it. Even on a low-DOF arm the concept earns its keep: dropping one orientation coordinate from a grasp task is often the difference between a stalled solver at a joint limit and a clean reach, and knowing which coordinate the task does not need is a design decision you will make explicitly when defining grasp candidates in Lesson 6.

A solver you can trust: DLS in numpy

Here is the complete pose-IK loop, written against the FK and Jacobian functions you built in the previous two lessons. Note what makes it trustworthy rather than merely functional: unit weighting applied consistently to residual and Jacobian, a trust-region step cap, joint-limit clamping (legitimate in pose IK, where iterates are never executed), stall detection, and a return contract that never hides failure.

ik_dls.py — damped-least-squares pose IK with honest reportingpython
import numpy as np

def rotation_error(R_current, R_target):
    """Axis-angle vector of the rotation taking R_current to R_target."""
    R_err = R_target @ R_current.T
    cos_t = np.clip((np.trace(R_err) - 1.0) / 2.0, -1.0, 1.0)
    theta = np.arccos(cos_t)
    if theta < 1e-9:
        return np.zeros(3)
    axis = np.array([R_err[2, 1] - R_err[1, 2],
                     R_err[0, 2] - R_err[2, 0],
                     R_err[1, 0] - R_err[0, 1]]) / (2.0 * np.sin(theta))
    return theta * axis

def ik_dls(fk, jacobian, q0, p_target, R_target, q_min, q_max,
           lam=0.08, rot_weight=0.1, step_limit=0.3,
           pos_tol=1e-4, rot_tol=1e-3, max_iters=100):
    """fk: q -> (p, R). jacobian: q -> (6, n) geometric Jacobian, [v; w] rows.
    Always check result['converged'] before using result['q']."""
    q = np.array(q0, dtype=float)
    W = np.diag([1.0, 1.0, 1.0, rot_weight, rot_weight, rot_weight])
    best_q, best_score, prev_score = q.copy(), np.inf, np.inf
    for k in range(max_iters):
        p, R = fk(q)
        e = np.concatenate([p_target - p, rotation_error(R, R_target)])
        pos_err = np.linalg.norm(e[:3])
        rot_err = np.linalg.norm(e[3:])
        score = pos_err + rot_weight * rot_err
        if score < best_score:
            best_score, best_q = score, q.copy()
        if pos_err < pos_tol and rot_err < rot_tol:
            return {"q": q, "converged": True, "iters": k,
                    "pos_err": pos_err, "rot_err": rot_err}
        if abs(prev_score - score) < 1e-9:
            break                       # stalled: plateau, likely local minimum
        prev_score = score
        Jw = W @ jacobian(q)            # weight rotation rows of J and e alike
        ew = W @ e
        dq = Jw.T @ np.linalg.solve(Jw @ Jw.T + lam**2 * np.eye(6), ew)
        step = np.linalg.norm(dq)
        if step > step_limit:           # trust region: keep linearization valid
            dq *= step_limit / step
        q_new = np.clip(q + dq, q_min, q_max)
        if np.linalg.norm(q_new - q) < 1e-10:
            break                       # pinned against joint limits
        q = q_new
    p, R = fk(best_q)
    return {"q": best_q, "converged": False, "iters": k,
            "pos_err": float(np.linalg.norm(p_target - p)),
            "rot_err": float(np.linalg.norm(rotation_error(R, R_target)))}

def ik_with_restarts(fk, jacobian, p_target, R_target, q_min, q_max,
                     q_seed, n_restarts=5, seed=0):
    """Warm-start from q_seed (usually the current configuration),
    then random-restart inside the joint limits. Never hides failure."""
    rng = np.random.default_rng(seed)
    attempts = [np.asarray(q_seed)]
    attempts += [rng.uniform(q_min, q_max) for _ in range(n_restarts)]
    results = []
    for q0 in attempts:
        r = ik_dls(fk, jacobian, q0, p_target, R_target, q_min, q_max)
        if r["converged"]:
            r["restarts_used"] = len(results)
            return r
        results.append(r)
    worst_best = min(results, key=lambda r: r["pos_err"])
    worst_best["restarts_used"] = len(results)
    return worst_best                   # converged is False: caller must check

Two details deserve emphasis. The stall check compares successive scores, not iterates — a solver orbiting a local minimum can keep moving while making no progress, and the plateau is what reveals it. And the joint-limit np.clip inside the loop is a pose-IK privilege: fine here because only the final answer is executed, but exactly the element-wise clamping the QP section warned against for tracking. Same operation, different contract.

Studio exercise 01

Stress-test the solver and write its honest datasheet

Using your FK and Jacobian from the previous lessons, characterize ik_with_restarts on your arm's kinematics. (1) Sample 1,000 guaranteed-reachable targets by drawing random joint vectors inside the limits and running FK; solve each from a fixed home seed and report convergence rate, iteration histogram (p50/p95/p99), and wall-clock p99. (2) Sample 1,000 poses uniformly from a 0.7 m box around the base — many unreachable — and verify every failure is reported as one. (3) Take one reported failure for a reachable target and diagnose it: joint-limit pin, local minimum, or near-singular stall? (4) Write the five-line datasheet you would want from any vendor: success rate on reachable poses, p99 latency, and the failure taxonomy with rates.

Need a hint?

Sampling targets via FK of random joint vectors sidesteps the hardest part of workspace analysis — reachability is guaranteed by construction. For the diagnosis in (3), log the per-iteration score and sigma-min of the Jacobian: a score plateau with healthy sigma-min and a joint pinned at its bound is a limit block; a plateau with sigma-min below about 0.01 is singular damping; position and rotation errors trading off against each other indicate the weighted objective's local minimum.

Where this goes next: the previous lesson, The Jacobian: velocities, forces, and singularities, built the matrix this entire lesson leaned on; you have now spent it three ways — as a Newton linearization, a damped pseudoinverse, and a QP constraint row. Next, Camera models, calibration, and hand-eye moves to the other end of the pipeline: where grasp poses actually come from. The two lessons meet at the gripper — a 5 mm IK residual and a 5 mm hand-eye calibration bias produce identical misses — and the honest solver reporting you built here is what will let you tell them apart when the full pick-and-place oracle comes together.