Forward kinematics from scratch
Joint angles in, gripper pose out — forward kinematics is the one exactly-correct function in your whole robot stack. Build it as a chain of transforms, derive the two-link case by hand, and verify it against your physical WidowX AI to a measured millimeter-level error budget.
- Implement forward kinematics for a 6-DOF WidowX-class arm as a composition of per-joint transforms read from a URDF-like description.
- Derive the planar two-link FK closed form by hand and use it as a known-answer oracle in a unit-test suite.
- Distinguish reachable from dexterous workspace and set a numerically justified planning boundary for your arm.
- Verify FK against the physical arm and diagnose unit, sign, and frame errors from their error signatures.
Forward kinematics (FK) is the forward pass of a model with zero learned parameters: a deterministic, differentiable function mapping six joint angles to the gripper's pose in space. It is also the only function in your stack whose output you should trust completely — cameras lie, pose estimators drift, learned policies hallucinate, but FK is exact geometry evaluated in float64. That is why Phase 02 starts here: the classical oracle exists to separate perception failures from planning failures from execution failures, and FK is the measuring stick those comparisons rest on. When a π₀-class policy later emits joint-space actions at 50 Hz, FK is how you turn them into checkable statements about the world.
Foundations: Frames, Conventions, and the Geometry of Motion
Before deriving the forward kinematics (FK) equations, we must rigorously define the geometric objects and conventions that make the algebra unambiguous. The core abstraction is the homogeneous transformation matrix, a 4×4 matrix that encodes both the position and orientation of a coordinate frame. We adopt the standard convention that a transform maps coordinates of a point expressed in frame to coordinates in frame . This is a passive transformation: the physical point does not move; only its numerical description changes as we switch reference frames. If you are instead rotating the frame itself relative to a fixed point, you apply the inverse transform. This distinction is critical: confusing active and passive rotations is the most common source of sign errors in robotics.
The rotation matrix is an element of the special orthogonal group . It must satisfy two conditions: orthonormality () and proper rotation (). The translation vector has units of length (meters). The bottom row ensures that the matrix acts as an identity on the homogeneous coordinate, allowing us to treat rotation and translation as a single linear operation. This structure is why we call it a 'homogeneous' transform: it unifies affine transformations into a single matrix multiplication.
| Term | Definition | Physical Intuition |
|---|---|---|
| Frame | A rigid coordinate system (origin + 3 orthogonal axes) attached to a link. | A local 'viewpoint' fixed to the robot part. Frame 0 is the base; Frame N is the tool. |
| Active Rotation | Rotating a vector within a fixed frame. | Spinning a wrench while holding the table still. The vector changes; the frame stays. |
| Passive Rotation | Rotating the frame relative to a fixed vector. | Turning your head to look at a stationary object. The object stays; your view changes. |
| Singularity | A configuration where the Jacobian matrix loses rank. | The arm is 'stuck' in one direction. You cannot move the end-effector in that Cartesian direction regardless of joint velocities. |
| Dexterous Workspace | Subset of reachable space where all 6 DOF of pose are achievable. | The 'safe' zone where you can reach a point AND orient the gripper arbitrarily without hitting limits. |
A common pitfall is assuming that the fixed offset in a URDF is always a pure translation. In reality, the rpy (roll-pitch-yaw) parameters define a rotation between the parent frame and the joint axis. If you ignore this rotation and assume the joint axis is aligned with the parent frame's axes, your FK will be wrong for any non-zero joint angle. The general form of the per-joint transform is , where includes both the translation and the rotation , and is the rotation about the joint axis by angle .
Worked Example: Two-Link Planar Arm
Let us derive the FK for a two-link planar arm with link lengths m and m. The joint angles are ( rad) and ( rad). We will compute the end-effector position and orientation step-by-step, showing the intermediate matrix multiplication to clarify the geometric composition.
First, we compute the individual transforms. For joint 1: , . , . For joint 2: , . , . The cumulative angle for the end-effector orientation is .
Performing the multiplication, the position vector is the sum of the first link's position and the second link's position rotated into the base frame: . Substituting the values: m. m. The result m makes physical sense: the arm is symmetric about the x-axis, so the y-components cancel.
In the two-link example, why does the y-coordinate of the end-effector equal zero?
The kinematic chain: what an arm actually is
Strip the housing off a robot arm and you find a serial kinematic chain: rigid links connected by joints, each joint permitting exactly one degree of freedom of relative motion. A revolute joint rotates about a fixed axis; a prismatic joint translates along one. Arms are almost entirely revolute — motors like to spin — while prismatic joints show up in gantries, lifts, and gripper fingers. The 6-DOF WidowX AI is six revolute joints in series — Trossen numbers them 0 through 5 — plus a parallel-jaw gripper whose fingers translate without changing the arm's pose:
- Joint 0 — base yaw about the vertical axis, ±180°. Sweeps the whole arm around the table.
- Joint 1 — shoulder-level pitch, 0–180°. Together with the elbow, does most of the positional work.
- Joint 2 — elbow-level pitch, 0–135°, axis parallel to joint 1's. Shoulder plus elbow form the planar two-link arm we derive by hand below.
- Joint 3 — first wrist axis, ±90°.
- Joint 4 — second wrist axis, ±90°, for aiming the gripper.
- Joint 5 — roll about the tool axis, ±180°. The last three joints mostly steer orientation rather than position.
A configuration is the vector of joint angles, restricted to a box by the joint limits. This is exactly the action space your future learned policy emits — the Trossen OpenPI integration for this arm outputs joint positions plus a gripper command per timestep — so every claim you make about a VLA's task-space behavior passes through the function built here. Numbers to keep in your head for the WidowX AI: 0.769 m maximum reach, 1.4 m span, 1.5 kg rated payload, ~1 mm repeatability per Trossen's spec sheet.
Run that repeatability backwards through the geometry. At the full 0.769 m lever arm, 1 mm of fingertip displacement corresponds to mrad of equivalent joint error. Modern integrated actuators sense far finer than that — the AK60-6 bench actuator from the anatomy lesson carries a 21-bit magnetic encoder, about 3 μrad per count, which is microns at the fingertip — so the millimeter floor is not encoder quantization; it is gear-train backlash, structural flex, and controller deadband. FK turns exactly this kind of joint-space arithmetic into task-space statements, and its formal job description is:
One frame per link, one transform per joint
The construction is the frame tree from Lab 0 with parameters added. Attach a frame rigidly to every link. Joint then contributes a transform mapping link- coordinates into link- coordinates, factored into two pieces: a fixed offset — where the joint sits on its parent link, read from the URDF's origin xyz and rpy — and a motion term, a rotation by about the joint's unit axis :
The full chain is a product, read left to right from base to tip, closed with a fixed tool transform from the last joint's frame to the point you actually care about — usually the midpoint between the fingertips:
You will meet two other encodings of the same idea. Denavit–Hartenberg (DH) parameters compress each link into four scalars by imposing strict rules on where frames may sit — the textbook standard, worth recognizing on sight, but unintuitive, and its two incompatible conventions (standard and modified) produce silent off-by-one-link errors when mixed. The product of exponentials (PoE) from Lynch and Park's Modern Robotics goes the other way: no intermediate frames at all. Each joint is a screw axis in the base frame, and the chain is , with the zero-configuration pose. We implement the URDF-style frame chain because that is the artifact your arm ships with — but keep PoE as the mental model.
| Encoding | Per-joint data | Strengths | Watch out for |
|---|---|---|---|
| Frame chain (URDF style) | origin xyz + rpy, axis vector | Matches the file your arm ships with; every intermediate frame is inspectable and debuggable | Verbose; frames proliferate; easy to lose track of which frame a vector lives in |
| Classical DH | 4 scalars: a, alpha, d, theta | Compact; universal in textbooks and older papers | Rigid frame-placement rules; standard vs modified conventions differ silently |
| Product of exponentials | one screw axis per joint, plus home pose M | No intermediate frames; cleanest math; Jacobian columns fall out naturally next lesson | One extraction step removed from URDF data; requires screw-theory vocabulary |
Your per-joint transforms are defined so that maps coordinates expressed in link-'s frame into link-'s frame. The gripper pose in the base frame is:
Derive it by hand: the planar two-link arm
Project the WidowX AI onto the vertical plane through its forearm and you get the classic two-link planar arm: shoulder joint, upper-arm link m, elbow joint, forearm-plus-wrist link m to the fingertips (illustrative lengths for a 0.77 m-reach arm — the exact link geometry is not published). Every essential FK idea is visible in this 2D toy, and the closed form derived here becomes the known-answer oracle for the 6-DOF code. In the plane a pose is and a homogeneous transform is 3×3. Define one reusable block — rotate by θ, then translate along the new x-axis:
Chain one block per joint and multiply, . Work the blocks separately. The rotation parts compose by the angle-addition identities — composing rotations in the plane is literally adding angles — and the second link's translation gets rotated into the base frame before being added:
Interrogate the formula where you already know the answer. At : m, — fully extended. At : — straight up. At : — the forearm folds back and the fingertip lands on the shoulder. These three-second checks catch a remarkable fraction of real bugs, and they become the known-answer tests below. Notice also what the formula quietly promises for Lesson 3: a desired generically has two solutions — elbow-up and elbow-down — which merge into one near full extension. Hold that picture; inverse kinematics is built on it.
The general algorithm: six DOF from a URDF-like description
In 3D nothing conceptual changes — only bookkeeping. Each URDF joint element hands you the fixed offset (origin xyz, rpy) and the axis vector; the algorithm walks the chain multiplying fixed times motion. Two choices worth copying: use Rodrigues' formula for every joint rotation instead of special-casing x/y/z axes (the URDF may specify any unit vector, and sign errors hide in special cases), and return the intermediate link frames, not just the final pose — next lesson's Jacobian is assembled directly from them.
import numpy as np
def hat(w):
wx, wy, wz = w
return np.array([
[0.0, -wz, wy],
[ wz, 0.0, -wx],
[-wy, wx, 0.0],
])
def rot_axis(axis, theta):
w = np.asarray(axis, dtype=float)
w = w / np.linalg.norm(w)
K = hat(w)
return np.eye(3) + np.sin(theta) * K + (1.0 - np.cos(theta)) * (K @ K)
def make_T(R, p):
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = p
return T
# Offsets and axes below are illustrative approximations of a 0.77 m-reach
# 6-DOF arm (exact WidowX AI link geometry is not published). Read the real
# values from the URDF in Trossen's ROS 2 description for the WidowX AI.
CHAIN = [
dict(name="joint0_base_yaw", xyz=[0.000, 0.0, 0.110], axis=[0, 0, 1]),
dict(name="joint1_shoulder", xyz=[0.000, 0.0, 0.050], axis=[0, 1, 0]),
dict(name="joint2_elbow", xyz=[0.060, 0.0, 0.300], axis=[0, 1, 0]),
dict(name="joint3_wrist", xyz=[0.170, 0.0, 0.000], axis=[1, 0, 0]),
dict(name="joint4_wrist", xyz=[0.115, 0.0, 0.000], axis=[0, 1, 0]),
dict(name="joint5_roll", xyz=[0.075, 0.0, 0.000], axis=[1, 0, 0]),
]
TOOL = make_T(np.eye(3), [0.110, 0.0, 0.0]) # flange -> fingertip midpoint
def fk(q, chain=CHAIN, tool=TOOL):
"""q: joint angles in radians. Returns (T_base_ee, per-link frames)."""
T = np.eye(4)
frames = []
for joint, theta in zip(chain, q):
fixed = make_T(np.eye(3), joint["xyz"]) # rpy is zero on this arm
motion = make_T(rot_axis(joint["axis"], theta), np.zeros(3))
T = T @ fixed @ motion
frames.append(T.copy()) # keep these: the Jacobian reads them
return T @ tool, framesCount the work: six iterations of two 4×4 multiplies — around a thousand FLOPs, where numpy's ~20 μs per-call overhead dominates the arithmetic; a JAX-jitted or C version runs well under a microsecond. Spend that cheapness everywhere: log the FK pose alongside raw joint states on every tick of the Lab 0 episode logger (task-space trajectories for free), have the safety watchdog run FK on every outgoing command to reject configurations outside the workspace box before dispatch, and let the planners of Lessons 3 and 7 call it in their inner loops without a second thought.
Workspace: reachable is not usable
Sweep over the joint-limit box and collect the FK positions: that is the reachable workspace — for the WidowX AI, roughly a 770 mm-radius spherical region around the shoulder, hollowed out by the base column, the table, and joint limits. The dexterous workspace is the subset where the gripper attains not just the position but the orientations your task needs — for tabletop work, usually a top-down grasp with the wrist vertical. It is dramatically smaller, because reaching far and pointing down compete: a vertical wrist folds the final ~100 mm of linkage downward instead of outward, shortening effective reach by that much.
The last few centimeters before maximum reach are unusable even for pure positioning. Reaching them forces the elbow toward fully straight, where three things degrade at once: the elbow-up and elbow-down IK branches merge (no alternative posture if one collides), the achievable-orientation set collapses toward a single wrist direction, and radial corrections become second-order — retracting the fingertip a few millimeters demands a startling amount of elbow motion. Quantify the last with the two-link model: with , the fingertip radius is , so a retraction from full extension costs:
The practical rule: plan inside about 85% of maximum reach — for the WidowX AI, keep pick-and-place targets within roughly 650 mm, preferably in the 250–550 mm band where the dexterous workspace is fat. Map yours empirically with a Monte Carlo sweep: sample configurations uniformly within joint limits, run vectorized FK (a second or two of numpy), scatter-plot positions from the top and side, then repeat keeping only samples whose gripper z-axis is within 10° of vertical to see the top-down-grasp region. Save both plots — they justify the workspace limits in Lab 0's watchdog and define the task region every later lesson inherits.
Your planner places a grasp target 750 mm from the base. FK confirms configurations exist that reach it (maximum reach: 769 mm). Why will execution be unreliable anyway?
Verification: trust, but measure
Verify FK the way you would verify a hand-written CUDA kernel: known-answer tests first, an independent reference second, hardware last. Tier 1 — analytic. Configurations you can answer by inspection: the home pose (sum the offsets by hand), single-joint sweeps (joint-0-only rotation must yaw the home position about base z and leave height unchanged), the planar two-link oracle derived above, and orthonormality at random configurations. Tier 2 — reference. Compare against an implementation you did not write — any URDF-loading kinematics library fed Trossen's ROS 2 description of the WidowX AI — at 1,000 random configurations; agreement should sit at the 1e-10 level, and any real discrepancy is a convention mismatch, almost always rpy order or an axis sign. Tier 3 — physical. Command real configurations and measure the fingertip with a ruler. In code:
import numpy as np
from fk import fk, make_T, CHAIN
def test_home_pose():
T, _ = fk(np.zeros(6))
# All zeros: arm stretched along +x. Sum the offsets by hand:
# x: 0.060 + 0.170 + 0.115 + 0.075 + 0.110 (tool) = 0.530
# z: 0.110 + 0.050 + 0.300 = 0.460
assert np.allclose(T[:3, 3], [0.530, 0.0, 0.460], atol=1e-12)
def test_base_yaw_is_pure_yaw():
home, _ = fk(np.zeros(6))
x, y, z = home[:3, 3]
T, _ = fk(np.array([np.pi / 2, 0, 0, 0, 0, 0]))
# +90 deg of joint 0 maps (x, y, z) -> (-y, x, z); height untouched
assert np.allclose(T[:3, 3], [-y, x, z], atol=1e-12)
def test_rotation_stays_orthonormal():
rng = np.random.default_rng(0)
for _ in range(1000):
q = rng.uniform(-np.pi, np.pi, size=6)
R = fk(q)[0][:3, :3]
assert np.linalg.norm(R.T @ R - np.eye(3)) < 1e-13
assert abs(np.linalg.det(R) - 1.0) < 1e-13
def test_matches_planar_two_link_oracle():
# Rebuild the hand-derived 2-link arm with the same machinery.
chain = [
dict(name="j1", xyz=[0.00, 0.0, 0.0], axis=[0, 0, 1]),
dict(name="j2", xyz=[0.25, 0.0, 0.0], axis=[0, 0, 1]),
]
tool = make_T(np.eye(3), [0.25, 0.0, 0.0])
for t1, t2 in [(0.3, 0.5), (-0.7, 1.1), (0.0, -0.4)]:
T, _ = fk(np.array([t1, t2]), chain=chain, tool=tool)
x = 0.25 * np.cos(t1) + 0.25 * np.cos(t1 + t2)
y = 0.25 * np.sin(t1) + 0.25 * np.sin(t1 + t2)
assert np.allclose(T[:2, 3], [x, y], atol=1e-12)For the physical tier: tape a printed millimeter grid to the table, register its origin to the robot base, command 5–8 configurations spanning the workspace, let the arm settle, and measure the fingertip with calipers or a steel rule. Expect 3–10 mm of disagreement, growing with extension as gravity sag accumulates. Resist verifying FK with your RealSense instead — its extrinsics are unverified until Lesson 4's hand-eye calibration, so you would be testing one uncalibrated system against another. The goal is not small numbers; it is knowing your FK error budget before Lessons 4 and 5 stack calibration and pose-estimation errors on top. When the numbers are wrong, the shape of the error names the bug:
- Joint effect ~57× too large or too small: degrees passed where radians were expected. The 57.3 signature is unmistakable.
- Motion mirrored relative to prediction: a joint axis sign flipped, or rpy rotations applied in the wrong order.
- Constant offset in one coordinate at every configuration: wrong base-frame origin or a missing tool transform.
- Error growing smoothly with extension: unmodeled gravity sag, or a link length off by a few millimeters.
Numerical hygiene, briefly — mostly a non-issue you should know is a non-issue. One FK evaluation in float64 leaves the rotation orthonormal to ~1e-15; six matrix multiplies cannot hurt you. Drift becomes real only when rotations are built incrementally: integrating velocity commands or IMU rates at 100 Hz to 1 kHz composes to small rotations per hour, and creeps into the 1e-9 range while the determinant wanders off 1. Two rules cover this course: recompute FK from from scratch every tick — statelessness is the cheapest hygiene — and where you must integrate, carry a quaternion and normalize each step, or project back to with an SVD () when the departure exceeds ~1e-9.
FK for your arm, verified against the metal
Build the full three-tier verification for your own WidowX AI. (1) Extract the real xyz/rpy/axis chain from the URDF in Trossen's ROS 2 description package for the WidowX AI and load it into the fk() implementation above. (2) Write the Tier-1 suite: home pose from hand-summed offsets, one single-joint sweep per joint checking motion direction, the planar two-link oracle, and orthonormality over 1,000 random configurations. (3) On hardware: command six configurations spanning the workspace, measure fingertip position against a taped grid, and tabulate FK-predicted vs measured positions with per-axis errors. Conclude with two sentences: your measured FK error budget, and which error signature (if any) you chased down.
Need a hint?
Pull the URDF from Trossen's ROS 2 description package for the WidowX AI, referenced in the Trossen docs; the fastest way to read the chain is to grep the joint elements for origin and axis. When a single-joint sweep moves the wrong way, check the sign in your transcription before touching the math — the URDF is usually right, the copy usually wrong. Measure at configurations where the wrist is vertical: an angled fingertip under calipers adds 2–3 mm of error that has nothing to do with your model.
Where this goes next: Lab 0: bring-up, safety, and the episode logger gave the robot instrumented senses and a safe way to move; this lesson gave it a geometric self-model — an exact map from joint angles to gripper pose with a measured error budget attached. The next step is to differentiate that map: The Jacobian: velocities, forces, and singularities asks what happens when each wiggles, and turns the per-link frames your fk() already returns into the matrix governing velocities, forces, and the singularities you just met at the workspace boundary. The rest of the classical oracle — IK in Lesson 3, calibrated cameras in Lesson 4, the full pick-and-place baseline — stands on those two functions.