Principles of Robot Autonomy I · Lesson 2 of 15 · Stanford AA274A

Frames, Transforms, and the Kinematics of Wheels

"Go forward one meter, then turn thirty degrees" is a wish, not a command. Turning it into left-and-right wheel speeds is the job of this lesson — the geometry and the kinematics that power the Control module of the autonomy stack.

Prerequisites: vectors & dot products + sine/cosine + a little Python. Matrices we build from scratch.
11
Chapters
6
Simulations
0
Assumed Knowledge

Chapter 0: "Forward" Is Not a Direction on the Map

You stand behind a small robot and tell it: "go forward one meter, then turn thirty degrees and drive again." It is a perfectly clear instruction to a human. To the robot it is almost meaningless — because "forward" is not a fixed arrow on the floor. It is whichever way the robot happens to be pointing, and that changes the instant the robot turns.

Here is the crux. The world has a fixed reference frame — call it the world frame, with a fixed x-axis (say "east") and y-axis ("north"). The robot has its own body frame that is glued to its chassis: its x-axis always points out its nose, its y-axis out its left side. When the robot rotates, its body frame rotates with it, but the world frame does not budge.

"Go forward" means "move along the body x-axis." But the planner upstream (Lesson 3 and beyond) hands the robot a goal in world coordinates: "be at the point (4.2, 1.8) facing 90°." Somebody has to translate between the two. And the wheels themselves speak yet a third language — left-wheel and right-wheel speeds. Nothing in this chain talks to anything else without a translator.

The one-sentence problem. A robot can only command motion in its own body frame, but it must reach goals expressed in the world frame, and ultimately move by spinning physical wheels. This lesson builds the three dictionaries that connect them: frames → rotations → rigid transforms (the geometry) and configuration → constraints → wheel commands (the kinematics).

Let's make the problem visceral. Below is a robot with a heading you can spin. Press a "go forward" button and watch where it actually goes. The same command — "advance 1 unit along your nose" — sends it to a completely different place on the map depending on which way it faces. That mismatch between body-frame command and world-frame result is the entire reason the next eleven chapters exist.

"Forward" depends on heading

Spin the heading θ with the slider. Press go forward 1m — the robot advances along its own nose, not along the world's east axis. The dashed grey arrow is "east"; the green arrow is where the body-frame command actually pushes it. Same command, different world outcome.

θ 40° heading 40°

Notice the numbers in the readout. When the robot faces 0° (due east), "forward 1m" moves it by (+1.00, +0.00) in world coordinates. Turn it to 90° (due north) and the identical command moves it by (+0.00, +1.00). Turn it to 40° and you get (+0.77, +0.64). That last pair is not a coincidence — it is cos40° and sin40°, and recognizing why is the seed of everything that follows.

Where this lesson lives on the stack. Lesson 1 introduced the four-module autonomy stack: Perception, Localization, Planning, and Control, all wrapped by the Executive. This lesson is the foundation of Control: the planner produces a trajectory in the world frame, and the kinematics here are what turn each desired motion into wheel commands the robot can physically execute. No kinematics, no motion.
Planner says
"reach (4.2, 1.8) facing 90°" (world frame)
Kinematics
convert to body-frame motion (v, ω)
Wheels do
left speed ωL, right speed ωR
Why is "go forward one meter" an ambiguous command to a robot until you know its heading?

Chapter 1: Two Frames, One Point — Coordinates Are a Choice

A point in space is just a point. But its coordinates — the pair of numbers we write down for it — depend entirely on which reference frame we measure from. The same charging dock can be "(3, 1) in the world" and "(2, −1) as seen by the robot" at the same instant. Both are correct. They describe one physical spot through two different lenses.

A reference frame (or coordinate frame) is an origin point plus a set of axis directions. We will use two throughout:

When we write a point's coordinates we should always say which frame they are in. The standard notation puts the frame as a leading superscript: Wp is "point p expressed in the world frame," and Bp is "the same point expressed in the body frame." Same p, two columns of numbers.

The central question of this entire lesson. Given the same physical point, how do I convert its coordinates from one frame to another? Wp ↔ Bp. Answering that for rotation alone gives SO(2) (Chapter 2); answering it for rotation and displacement gives the rigid-body transform SE(2) (Chapter 3). Every "where am I relative to the dock?" question a robot ever asks is one of these conversions.

A worked frame conversion, by hand

Suppose the robot sits at world position (2, 1) and — to keep this chapter pure translation — is not rotated: its body axes are parallel to the world's. A charging dock is at world point Wp = (5, 3). What are the dock's coordinates in the body frame?

Geometrically, the body frame's origin is at (2, 1). To get the dock's position as seen from the robot, subtract the robot's origin from the dock's world position:

Bp = Wp − OB = (5, 3) − (2, 1) = (3, 2)

So the dock is "3 units ahead and 2 units to the left" of the robot. Check it the other way: if the robot knows the dock is at Bp = (3, 2) in its own frame, it recovers the world position by adding its origin back: (3, 2) + (2, 1) = (5, 3). The conversions are inverses, as they must be.

That subtraction handled displacement only, because we forced the frames to be parallel. The moment the robot can rotate, a plain subtraction is no longer enough — we will need to rotate the difference vector too. That rotation is the subject of Chapter 2, and it is where matrices enter. For now, play with the parallel-frames case so the "same point, two readouts" idea is concrete.

Same point, two frames

Drag the purple point anywhere. Drag the robot (the body-frame origin, in teal) to move where "you" are standing. Read the same point's coordinates in the world frame (blue axes) and the body frame (teal axes). Here the body frame is un-rotated, so the body coordinates are just the difference — the rotated case comes next chapter.

drag the point or the robot
Common confusion. "Moving the point" and "moving the frame" feel like opposites, and they are. If the dock stays put but the robot drives 1 unit east, the dock's world coordinates are unchanged but its body coordinates shift by −1 in x. A frame transform is always relative: ask yourself "what moved — the thing, or the observer?" The math is the same either way, but the sign flips, and mixing them up is the single most common bug in robot geometry code.

From-scratch code: the parallel-frame conversion

python
import numpy as np

# Robot (body-frame origin) and a point, both in WORLD coordinates.
O_B   = np.array([2.0, 1.0])   # where the robot stands
p_world = np.array([5.0, 3.0]) # the dock

# Frames are PARALLEL (no rotation yet) -> conversion is a subtraction.
p_body  = p_world - O_B          # [3., 2.]  "3 ahead, 2 left"
p_back  = p_body  + O_B          # [5., 3.]  recovers the world point

print(p_body, p_back)             # [3. 2.] [5. 3.]  -- inverses, as required
A landmark is at world position (7, 4). A robot (axes parallel to the world) stands at (3, 4). What are the landmark's coordinates in the robot's body frame?

Chapter 2: Rotation in the Plane — Deriving SO(2) From Scratch

Chapter 1's subtraction worked only because the frames were parallel. To handle a robot that has turned, we need a machine that takes a vector and rotates it by an angle θ. That machine is a 2×2 matrix, and we are going to build it, not look it up.

The trick to deriving any rotation rule is to ask only one question: where do the basis vectors go? If I know where the x-axis unit vector and the y-axis unit vector land after rotation, I know where everything lands, because every vector is just a combination of those two.

Rotating the two basis vectors, by hand

Start with the x-axis unit vector, ex = (1, 0). Rotate it counter-clockwise by an angle θ about the origin. Trigonometry of the unit circle tells us its tip traces from angle 0 to angle θ, so it lands at:

R·ex = (cosθ, sinθ)

Now the y-axis unit vector, ey = (0, 1). It already sits at angle 90°; rotating it by θ sends it to angle 90° + θ. Using cos(90°+θ) = −sinθ and sin(90°+θ) = cosθ:

R·ey = (−sinθ, cosθ)

A matrix's columns are exactly "where the basis vectors go." So we stack those two results as columns and the rotation matrix simply appears:

R(θ) = [ cosθ  −sinθ ;  sinθ   cosθ ]

(first row: cosθ, −sinθ  ·  second row: sinθ, cosθ)

A fully numeric example: rotate (1, 0) and (0, 1) by 30°

Plug in θ = 30°, where cos30° ≈ 0.866 and sin30° = 0.5. The matrix is:

R(30°) ≈ [ 0.866  −0.500 ;  0.500   0.866 ]

Apply it to ex = (1, 0). Matrix-times-vector means "row dotted with the vector," one row at a time:

R(30°)·(1,0) = ( 0.866·1 + (−0.500)·0 ,  0.500·1 + 0.866·0 ) = (0.866, 0.500)

The x-axis tipped up to 30° — exactly cos30° right and sin30° up. Now apply it to ey = (0, 1):

R(30°)·(0,1) = ( 0.866·0 + (−0.500)·1 ,  0.500·0 + 0.866·1 ) = (−0.500, 0.866)

The y-axis swung left and up, landing at 120°, exactly 90° + 30°. The matrix does precisely what our basis-vector reasoning predicted.

A second numeric example: rotate the vector (2, 1) by 90°

At θ = 90°, cos = 0 and sin = 1, so R(90°) = [0, −1 ; 1, 0]. Apply it to (2, 1):

R(90°)·(2,1) = ( 0·2 + (−1)·1 ,  1·2 + 0·1 ) = (−1, 2)

Sanity check by hand: a 90° counter-clockwise turn should send "right and a little up" to "up and a little left." (2, 1) was mostly-rightward; (−1, 2) is mostly-upward. Correct. Rotating by 90° always does (x, y) → (−y, x), which you can now read straight off the matrix.

The deep properties of R(θ). Three facts make rotation matrices special, and together they define the group SO(2) ("special orthogonal group in 2D"):
Orthogonal: its columns are perpendicular unit vectors, so RR = I (the identity). Rotation preserves lengths and angles — it never stretches or shears.
Determinant +1: det R = cos²θ + sin²θ = 1. (A determinant of −1 would be a reflection, which flips handedness — not a real rotation.)
Inverse is the transpose: R−1 = R = R(−θ). To undo a rotation you just rotate the other way, and you get it for free by flipping the matrix — no expensive inversion.

Verify R−1 = R with the 30° matrix. Its transpose (swap off-diagonal entries) is [0.866, 0.500 ; −0.500, 0.866], which is exactly R(−30°) since cos is even and sin is odd. Multiply them: R(30°)·R(−30°) = R(0°) = I. Undo and redo cancel, as geometry demands.

The rotation dial — watch the matrix come alive

Turn the dial to set θ. The teal vector is the rotated x-axis R·ex; the purple vector is the rotated y-axis R·ey. The live matrix readout shows cosθ/sinθ filling its four slots — the columns are those two vectors. Notice they always stay perpendicular and unit length: that is orthogonality, on screen.

θ 30°
Common confusion: which way does −sinθ go? Many people memorize the matrix with the minus sign in the wrong corner, which silently rotates clockwise instead of counter-clockwise. Don't memorize — re-derive. Ask "where does (1,0) go?" If θ is a small positive (CCW) angle, the x-axis tip should rise (positive y), so the second entry of the first column is +sinθ, which forces the −sinθ into the top-right. Re-deriving from the basis vectors takes ten seconds and never lies.

From-scratch code vs. the library one-liner

python
import numpy as np

# From scratch: build R(theta) by typing in the four entries.
def rot2(theta):
    c, s = np.cos(theta), np.sin(theta)
    return np.array([[c, -s],
                     [s,  c]])

th = np.deg2rad(30)
R  = rot2(th)
print(R @ np.array([1.0, 0.0]))   # [0.866 0.5  ] -- rotated x-axis
print(R @ np.array([0.0, 1.0]))   # [-0.5   0.866] -- rotated y-axis

# Properties, verified numerically:
print(np.round(R.T @ R, 9))         # identity  -> orthogonal
print(np.round(np.linalg.det(R), 9)) # 1.0       -> a rotation, not a reflection
print(np.allclose(np.linalg.inv(R), R.T))  # True  -> inverse is the transpose

# The library one-liner (scipy) does the same thing in 3D-aware form:
# from scipy.spatial.transform import Rotation as Rot
# R3 = Rot.from_euler('z', 30, degrees=True).as_matrix()  # 3x3, z-rotation
Why is the inverse of a rotation matrix equal to its transpose, R−1 = R?

Chapter 3: Rotation + Translation = SE(2) — One Matrix to Rule a Frame

A real robot does two things at once: it sits somewhere (translation) and faces some way (rotation). Chapter 1 gave us translation; Chapter 2 gave us rotation. Now we fuse them into a single object — the rigid-body transform — that captures a frame's complete pose in one matrix.

Physically, to convert a point from the body frame to the world frame you must do both operations in the right order: first rotate the body-frame coordinates so the axes line up with the world's, then translate by the body origin's world position. In symbols:

Wp = R(θ)·Bp + t

Here t = (tx, ty) is the body origin's position in the world — the "where the robot stands" vector — and R(θ) is the rotation that aligns the body axes to the world. This "rotate then add" form is correct and complete. But carrying a separate matrix and vector around, and remembering the order, is error-prone. So we play a clever trick.

Why homogeneous coordinates

We want a single matrix that does "rotate and translate" in one multiply. The obstacle: matrix multiplication can only scale and mix coordinates (that gives rotation), it can never add a constant (that is what translation needs). The fix is to add a fake third coordinate, always equal to 1, to every point:

p = (x, y) → p̃ = (x, y, 1)

This "1" is a smuggler. When a matrix multiplies it, the entries in the matrix's third column get added in as constants — which is exactly translation. We call (x, y, 1) the homogeneous coordinates of the point, and the 3×3 matrix that packages R and t together is the SE(2) transform ("special Euclidean group in 2D"):

T = [ cosθ −sinθ  tx ;  sinθ  cosθ  ty ;  0    0    1 ]

top-left 2×2 block is R(θ); top-right column is t; bottom row is (0, 0, 1)

The bottom row (0, 0, 1) is bookkeeping: it keeps the output's third coordinate equal to 1, so the result is still a valid homogeneous point you can transform again. A whole pose — position and heading — now lives in one matrix.

A point transform, fully by hand

Robot at world position t = (2, 1), rotated θ = 90°, so R(90°) = [0, −1 ; 1, 0]. A point sits at Bp = (3, 0) in the body frame — "3 units straight ahead of the robot." Build T and apply it:

T = [ 0 −1  2 ;  1  0  1 ;  0  0  1 ] ,     p̃ = (3, 0, 1)

Multiply row by row:

row 1: 0·3 + (−1)·0 + 2·1 = 2  (world x)
row 2: 1·3 + 0·0 + 1·1 = 4  (world y)
row 3: 0·3 + 0·0 + 1·1 = 1  (the bookkeeping 1)

So Wp = (2, 4). Sanity check: the robot stands at (2, 1) facing north (90°), and the point is 3 ahead of it. "3 ahead" while facing north means 3 units up in y, landing at (2, 1+3) = (2, 4). The matrix agrees with the picture exactly — the θ=90° rotation turned "3 ahead in body x" into "+3 in world y," then the translation shifted by (2, 1).

Why this is the killer feature: composition is just multiplication. The real power of SE(2) is chaining. If frame B's pose in the world is TWB, and frame C's pose relative to B is TBC, then C's pose in the world is simply the product TWC = TWB · TBC. The subscripts cancel like fractions — the inner B matches — which is your built-in correctness check. Want to undo a transform? Use the inverse T−1. Chains of arms, sensors-on-robots, robots-on-maps: all are products of these matrices.

Composing two transforms, by hand

Let the robot's pose in the world be TWB: position (2, 1), heading 0° (axes parallel to world), so R = I. Mounted on the robot is a camera at body position (1, 0) rotated 90°, giving the camera's pose-on-robot TBC. We want the camera's pose in the world, TWC = TWB · TBC.

TWB = [ 1 0 2 ; 0 1 1 ; 0 0 1 ] ,    TBC = [ 0 −1 1 ; 1 0 0 ; 0 0 1 ]

Because TWB is the identity rotation with a (2,1) shift, multiplying just adds (2,1) to the translation column of TBC and leaves its rotation block alone:

TWC = [ 0 −1 3 ; 1 0 1 ; 0 0 1 ]

Read it off: the camera is in the world at (3, 1) — the robot's (2,1) plus the camera's (1,0) offset — rotated 90°. With a non-identity robot rotation the camera offset would itself get rotated first; the matrix product handles that automatically. That is the whole point of doing it with matrices instead of bookkeeping by hand.

Chain two frames: TWB · TBC

Frame B (teal) sits in the world; frame C (purple) is attached to B. Use the sliders to set B's heading and C's offset/heading. The purple frame is drawn by composing the two transforms — rotate C's offset into the world, then place it. Watch how turning B swings C around it.

θB θC 45°
T_WC computed from T_WB · T_BC
Common confusion: order matters. TWB·TBC is not the same as TBC·TWB — matrix multiplication does not commute, just as "walk 3 steps then turn left" differs from "turn left then walk 3 steps." The rule that saves you: write the chain so adjacent subscripts match (TWB·TBC: the inner B's touch) and the outer subscripts give the answer (W and C). If the inner subscripts don't match, you have the order wrong or you need an inverse.
python
import numpy as np

def SE2(x, y, theta):
    c, s = np.cos(theta), np.sin(theta)
    return np.array([[c, -s, x],
                     [s,  c, y],
                     [0,  0, 1]])

def apply(T, p):
    p_h = np.array([p[0], p[1], 1.0])   # homogeneous: append the smuggler 1
    return (T @ p_h)[:2]                # drop the bookkeeping coordinate

T_WB = SE2(2, 1, np.deg2rad(90))      # robot pose in world
print(apply(T_WB, [3, 0]))         # [2. 4.]  -- "3 ahead" while facing north

T_BC = SE2(1, 0, np.deg2rad(90))      # camera-on-robot
T_WC = T_WB @ T_BC                  # compose: camera pose in world
T_WB_inv = np.linalg.inv(T_WB)      # undo a transform (a frame's inverse pose)
What is the purpose of the constant "1" appended to a point's coordinates (homogeneous form) inside an SE(2) transform?

Chapter 4: Configuration — The Numbers That Pin a Robot Down

SE(2) describes a single pose. But a moving robot has a pose at every instant, and to talk about motion we need to track those numbers as they change over time. The minimal set of numbers that completely specifies a robot's configuration are its generalized coordinates, collected in a vector written ξ (the Greek letter xi):

ξ = [ξ1, ξ2, …, ξn] ∈ ℝn

Here n is how many numbers it takes, n means "an ordered list of n real numbers," and the small just means we treat ξ as a column. The set of all possible configurations is the configuration space. For a wheeled robot rolling on a flat floor, three numbers suffice:

ξ = [x, y, θ]

where (x, y) is the robot's position and θ its heading — the same pose SE(2) packed into a matrix, now written as a plain vector for doing calculus on. The pair (position, orientation) is the robot's pose.

Trajectories: configuration as a function of time

A robot in motion is a configuration that changes with time, written ξ(t) — the trajectory. Its time-derivative is the velocity in configuration space:

ξ̇(t) = [ẋ, ẏ, θ̇]

The dot over a symbol (read "x-dot") means "rate of change." So ẋ and ẏ are the velocity components along the world axes, and θ̇ is the turning rate. Whole chunks of robotics — planning, control, estimation — are really statements about ξ(t) and ξ̇(t).

Degrees of freedom vs. number of coordinates. A planar robot has n = 3 generalized coordinates. But that does not mean it can instantaneously move in 3 independent directions. A car can sit at any (x, y, θ), yet at any instant it cannot slide sideways. The configuration space is 3-dimensional; the velocities you can command are fewer. The gap between "where you can eventually be" and "how you can move right now" is what Chapters 5–6 formalize — and it is the heart of why parking is hard.

The wheel example — why coordinates are a modeling choice

Consider a single wheel rolling on a line. What is its configuration? You might pick its position s along the line. But if you also care about which spoke is up — the wheel's rolling angle φ — then ξ = (s, φ) with n = 2. Yet for a wheel that rolls without slipping, s and φ are locked together: s = rφ (radius times angle). So the two coordinates have only one degree of freedom. Choosing coordinates is a modeling decision: include exactly what you need to specify the configuration, no more.

Below, set a planar robot's configuration directly and read its ξ vector, with the body frame drawn on top so you see the pose the three numbers encode.

The configuration vector ξ = (x, y, θ)

Drag the robot to set (x, y); use the slider for θ. The live readout is the generalized-coordinate vector ξ and its time-derivative slot ξ̇ (you'll fill it with real velocities once we have a motion model). The teal arrow is the body x-axis — the only direction the robot can drive, as the next chapters prove.

θ 35° drag the robot to move it
Common confusion: "configuration," "state," "pose," "generalized coordinates" — all the same here? For a simple wheeled robot, yes: all four are (x, y, θ). They diverge for richer robots. A drone's pose lives in 3D and needs 6 numbers; a 7-joint arm's configuration is 7 joint angles; a robot's full state in a controller often adds velocities too (x, y, θ, ẋ, ẏ, θ̇). Always ask the one question that defines ξ: what is the smallest set of numbers that completely pins this robot down?
python
import numpy as np

# A trajectory is just configuration as a function of time.
def xi_of_t(t):
    # example: drive east while slowly turning
    return np.array([0.5*t, 0.0, 0.1*t])   # [x, y, theta]

def xidot_num(t, h=1e-5):
    return (xi_of_t(t+h) - xi_of_t(t-h)) / (2*h)  # numerical d/dt

print(xi_of_t(2.0))      # [1.  0.  0.2]   pose at t=2
print(xidot_num(2.0))    # [0.5 0.  0.1]   velocity xi-dot
A planar wheeled robot has n = 3 generalized coordinates (x, y, θ). What does that NOT guarantee?

Chapter 5: Kinematic Constraints — The Pfaffian Form

Chapter 4 hinted that a robot's velocities are often more restricted than its configuration space. We now write those restrictions as equations. A kinematic constraint is a rule that the robot's configuration and/or velocity must obey at all times — "you cannot slide sideways," "this rod has fixed length." We write a constraint as a function that must stay zero:

a(ξ, ξ̇) = 0

Two flavors. A constraint on configuration alone — h(ξ) = 0 — restricts where the robot can be (a pendulum bob stuck at fixed distance from its pivot). A constraint on velocity — involving ξ̇ — restricts how the robot can move (no sideways sliding). The velocity constraints we care about are linear in the velocities: each velocity component appears to the first power, never squared or multiplied together. That linear-in-velocity form has a name — the Pfaffian form:

A(ξ) · ξ̇ = 0

Read it carefully. A(ξ) is a matrix whose entries depend only on where the robot is (its configuration), not on how fast it's going. It multiplies the velocity vector ξ̇, and the product must be zero. Each row of A is one constraint — one forbidden combination of velocities. If A has k rows, there are k constraints, and they carve down the allowed velocity directions.

The geometric meaning of the Pfaffian. Each row of A(ξ) is a vector. The equation "row · ξ̇ = 0" says the velocity must be perpendicular to that row vector. So a constraint forbids motion along one direction and permits everything orthogonal to it. With n = 3 coordinates and one constraint, the velocity is free to be any vector in the 2D plane perpendicular to that one row — a 2-dimensional space of allowed velocities. Constraints don't fix the robot in place; they thin out which velocity directions are legal.

Worked example: the planar pendulum, derived to Pfaffian form

A bead is connected to the origin by a rigid rod of length L; it can swing but the rod can't stretch. Its configuration is ξ = (x, y). The "rod can't stretch" rule is a constraint on position: the distance from the origin is always L. Squaring to avoid the square root:

h(ξ) = x² + y² − L² = 0

Use real numbers: L = 5, and the bead currently at (3, 4). Check: 3² + 4² − 5² = 9 + 16 − 25 = 0. On the circle, good. Now — here's the key move — differentiate the constraint with respect to time. As the bead moves, h must stay zero, so its rate of change is zero. Using the chain rule (d/dt of x² is 2x·ẋ):

d/dt [ x² + y² − L² ] = 2x·ẋ + 2y·ẏ = 0

Divide by 2 and you have a Pfaffian (velocity-linear) constraint:

x·ẋ + y·ẏ = 0  →   A(ξ) = [x  y] ,    ξ̇ = [ẋ ; ẏ]

At the point (3, 4): the constraint reads 3·ẋ + 4·ẏ = 0. The row vector (3, 4) points radially outward, and the rule says the velocity must be perpendicular to it — the bead can only move tangent to the circle. That matches physical intuition: a pendulum bob moves along its arc, never along the rod. We just derived that fact from one differentiation.

Numeric check of an allowed velocity. The direction tangent to the circle at (3, 4) is (−4, 3) (swap and negate — perpendicular to the radius). Plug in ξ̇ = (−4, 3): 3·(−4) + 4·3 = −12 + 12 = 0. Allowed. Try a radial (outward) velocity ξ̇ = (3, 4): 3·3 + 4·4 = 25 ≠ 0. Forbidden — the rod would have to stretch. The Pfaffian equation correctly accepts the tangent and rejects the radial.

Pendulum constraint: only tangent velocities pass

The bead is stuck on the circle of radius L. Drag the angle to move it, and aim a candidate velocity with the direction slider. The readout computes A·ξ̇ = x·ẋ + y·ẏ: it's 0 (green, allowed) only when your velocity is tangent, and nonzero (red, would stretch the rod) otherwise.

bead 53° vel dir 143°
A·ξ̇ = 0 means allowed
Common confusion: a constraint kills a velocity direction, not a position. The Pfaffian A·ξ̇ = 0 says nothing about where you are — it only forbids certain instantaneous velocities. A car obeying "no sideways motion" can still get to a parking spot directly to its left; it just can't go there in a straight slide. Whether a velocity constraint also secretly limits reachable positions is exactly the holonomic-vs-nonholonomic question of the next chapter.
python
import numpy as np

# Pendulum: A^T(xi) for h = x^2 + y^2 - L^2, derived by d/dt.
def A_pendulum(xi):
    x, y = xi
    return np.array([[x, y]])      # one row, one constraint

xi  = np.array([3.0, 4.0])     # on the circle of radius 5
v_tan = np.array([-4.0, 3.0])  # tangent (perpendicular to radius)
v_rad = np.array([3.0, 4.0])   # radial (outward)

print(A_pendulum(xi) @ v_tan)   # [0.]   allowed
print(A_pendulum(xi) @ v_rad)   # [25.]  forbidden (rod would stretch)
In the Pfaffian form A(ξ)·ξ̇ = 0, what does each ROW of A represent?

Chapter 6: Holonomic vs. Nonholonomic — The Difference That Makes Parking Hard

Both the pendulum and the no-slip wheel give a Pfaffian constraint A·ξ̇ = 0. But they are profoundly different. The pendulum's constraint secretly traps the bead on a circle — it shrinks the reachable space. The wheel's constraint forbids sideways motion at each instant, yet a car can still reach any (x, y, θ) given enough wiggling. That distinction is the most important idea in this lesson.

The litmus test, in plain words. Holonomic = "the constraint is really about where you can be" (loses reachable dimensions). Nonholonomic = "the constraint is only about how you can move right now" (loses no reachable dimensions). A planar wheeled robot has n = 3 coordinates and one nonholonomic no-slip constraint, so it still reaches the full 3D configuration space — it just can't take the direct path. That is why parallel parking exists: you can get the car sideways-left of where it is, but only by an arc-shuffle, never a slide.

Deriving the no-slip constraint from the geometry

A wheeled robot at heading θ has a body x-axis (its rolling direction) ev = (cosθ, sinθ), and a body y-axis (its axle direction, the forbidden direction) which is ev rotated 90°:

e = (−sinθ, cosθ)

The no-slip rule says the wheel cannot move along its axle — its velocity has zero component in the e direction. "Zero component along e" is exactly a dot product equal to zero. The robot's world velocity is (ẋ, ẏ), so:

e · (ẋ, ẏ) = (−sinθ)·ẋ + (cosθ)·ẏ = 0

Rearranging the signs to the conventional form:

ẋ sinθ − ẏ cosθ = 0

This is the famous no-slip Pfaffian constraint, with A(ξ) = [sinθ, −cosθ, 0] (the θ̇ column is 0 — turning in place doesn't violate no-slip). It depends on configuration through θ, exactly as the Pfaffian form promised.

A numeric check, and why it's nonholonomic

At θ = 90° (facing north): sin90° = 1, cos90° = 0, so the constraint is ẋ·1 − ẏ·0 = ẋ = 0. Translated: a north-facing robot may have any ẏ (drive north) and any θ̇ (turn), but ẋ must be zero — it cannot slide east or west. Exactly right: it can drive straight forward (north) or pivot, but not slide sideways.

Why can't we integrate this back to an h(ξ) = 0? Because the constraint row changes with θ, and as the robot turns, the "forbidden direction" rotates too. By alternately driving and turning, the robot threads its velocity through a forbidden direction that keeps moving — netting a sideways displacement no single allowed velocity could produce. There is no fixed surface it's trapped on. (The formal test is whether the constraints' Lie brackets stay inside the allowed directions; for the wheel they don't, which is the rigorous reason it's nonholonomic.)

Try to slide sideways — the no-slip wall

Drag the robot to push it around. With no-slip ON, your drag is projected onto the heading line (teal) — sideways motion (along the red axle line) is blocked, exactly as the constraint demands. Turn no-slip OFF (an omni-wheel robot) and it slides freely. Spin the heading and watch the forbidden red direction rotate with θ.

θ 60°
drag to move; sideways is blocked
Common confusion: "constrained" does not mean "stuck." Students see "the robot can't move sideways" and conclude it can't get to a spot beside it. False — that's the holonomic mistake. The no-slip constraint is nonholonomic: every configuration is still reachable, just not by a sideways slide. The reachable set is full-dimensional; only the instantaneous velocity set is thinned. Confusing these two is why people underestimate how clever motion planners (Lesson 3+) must be for cars.
python
import numpy as np

# No-slip constraint row for a wheeled robot at heading theta.
def A_noslip(theta):
    return np.array([[np.sin(theta), -np.cos(theta), 0.0]])  # [sin, -cos, 0]

th = np.deg2rad(90)                 # facing north
print(A_noslip(th) @ np.array([1.0, 0, 0]))  # [1.] -> sliding east FORBIDDEN
print(A_noslip(th) @ np.array([0, 2.0, 0]))  # [0.] -> driving north ALLOWED
print(A_noslip(th) @ np.array([0, 0, 1.0]))  # [0.] -> turning in place ALLOWED
A rolling wheel obeys the no-slip constraint (no sideways motion). Why is it called NONholonomic?

Chapter 7: The Unicycle Model — From Constraint to Motion

The no-slip constraint told us what the robot cannot do. The unicycle model flips that around and says what it can do, in the cleanest possible form. It is the workhorse model for nearly every wheeled robot in this course.

Recall the constraint ẋ sinθ − ẏ cosθ = 0 carves out a 2D space of allowed velocities inside the 3D configuration. We need a basis for that allowed space — two "knobs" that generate every legal velocity. They have obvious physical meaning:

Driving forward at speed v while pointed at θ advances the world position along the heading vector (cosθ, sinθ), and turning at rate ω changes θ directly. That gives the unicycle equations — three first-order differential equations for the configuration:

ẋ = v cosθ
ẏ = v sinθ
θ̇ = ω

The two control inputs are (v, ω); everything else follows. Plug these back into the no-slip constraint to confirm they obey it: ẋ sinθ − ẏ cosθ = (v cosθ)sinθ − (v sinθ)cosθ = 0. Always zero, for any v and ω — the unicycle model lives exactly on the allowed-velocity surface. The model is the constraint, solved.

Why "unicycle"? Picture a single wheel you can drive (set v) and steer in place (set ω). That's the abstraction. It has no real width, no two wheels — it's the simplest object obeying no-slip. Astonishingly, the differential-drive robot (Chapter 8), the steered "simple car," and even many drones-projected-to-the-floor all reduce to commanding some (v, ω). Master the unicycle and you've modeled most ground robots' motion.

Integrating one step, by hand

Numbers make it real. Start at ξ0 = (x, y, θ) = (0, 0, 0) — at the origin, facing east. Command v = 2 m/s and ω = 1 rad/s for a small time step Δt = 0.5 s. We use forward Euler integration: new value = old value + rate × Δt. Compute the rates at the current state first:

ẋ = v cosθ = 2·cos0 = 2·1 = 2.0
ẏ = v sinθ = 2·sin0 = 2·0 = 0.0
θ̇ = ω = 1.0

Now step each coordinate by rate × Δt:

x ← 0 + 2.0·0.5 = 1.0
y ← 0 + 0.0·0.5 = 0.0
θ ← 0 + 1.0·0.5 = 0.5 rad ≈ 28.6°

After the step the robot is at (1.0, 0.0) facing 0.5 rad. Take a second step from there, now with θ = 0.5: ẋ = 2cos0.5 ≈ 2·0.878 = 1.755, ẏ = 2sin0.5 ≈ 2·0.479 = 0.959. So x ← 1.0 + 1.755·0.5 = 1.88, y ← 0.0 + 0.959·0.5 = 0.48, θ ← 0.5 + 0.5 = 1.0 rad. The path curves leftward — constant v with constant ω traces a circular arc, exactly what you'd expect from "drive while turning at a steady rate."

Concept → realization. A controller (Lesson 6) computes (v, ω) to chase a planner's trajectory. The unicycle model is what turns those two numbers into the robot's predicted next pose — the same forward-Euler loop you just did by hand runs thousands of times per second inside a simulator and inside the robot's own predictor. Bigger Δt = faster but less accurate (Euler drifts on curves); that accuracy tradeoff is why real stacks sometimes use fancier integrators, but Euler is the honest starting point.
Drive the unicycle: set v and ω, watch the arc

Set forward speed v and turn rate ω, then press drive to integrate the unicycle equations forward with small Euler steps. v with ω=0 draws a straight line; nonzero ω bends it into a circular arc. The traced path is the trajectory ξ(t); the readout shows the live (x, y, θ).

v 1.4 ω 0.80
ξ = (0.0, 0.0, 0°)
Common confusion: v and ω are velocities, not positions. Setting ω = 1 does not turn the robot by 1 radian — it turns it at 1 radian per second. Nothing happens unless time passes. To rotate 90° (≈1.57 rad) at ω = 1 rad/s you must hold that command for ≈1.57 seconds. Likewise v is meters per second; to go 1 meter at v = 2 you drive for half a second. Forgetting the "per second" is the classic off-by-a-timestep robot bug.
python
import numpy as np

def unicycle_step(xi, v, w, dt):
    x, y, th = xi
    # rates from the current state, then forward-Euler step
    x  = x  + v*np.cos(th)*dt
    y  = y  + v*np.sin(th)*dt
    th = th + w*dt
    return np.array([x, y, th])

xi = np.array([0.0, 0.0, 0.0])
xi = unicycle_step(xi, v=2.0, w=1.0, dt=0.5)
print(np.round(xi, 3))   # [1.  0.  0.5]  matches the hand calc
xi = unicycle_step(xi, v=2.0, w=1.0, dt=0.5)
print(np.round(xi, 3))   # [1.878 0.479 1. ]  the path curves left
In the unicycle model ẋ = v cosθ, ẏ = v sinθ, θ̇ = ω, what motion results from constant v > 0 and constant ω ≠ 0?

Chapter 8: Differential Drive & the Simple Car — Real Wheels

The unicycle commands (v, ω) directly — but no real robot has a "ω knob." A TurtleBot has two driven wheels; you set their individual speeds, ωL and ωR. A car has a throttle and a steering wheel. We need to translate between those physical inputs and the unicycle's (v, ω). That translation is the last piece of the body-to-wheel dictionary.

Differential drive: two wheels, one body

A differential-drive robot has two wheels of radius r, mounted a distance L apart on a common axle (L is the wheelbase, or track width). Each wheel rolls without slipping, so a wheel spinning at angular rate ωR contributes a ground speed of r·ωR at its contact point (speed = radius × spin rate). The robot's forward speed is the average of the two wheel speeds; its turn rate comes from their difference:

v = r(ωR + ωL) / 2
ω = r(ωR − ωL) / L

The intuition is exactly how you'd steer a wheelchair or a tank. Spin both wheels equally and you go straight (the difference is zero, so ω = 0). Spin the right wheel faster than the left and you veer left (positive ω). Spin them opposite ways and you pivot in place (v = 0, the average is zero). These two formulas are the entire kinematic interface of a differential-drive robot.

Worked example, with real numbers

Wheel radius r = 0.05 m (5 cm), wheelbase L = 0.30 m (30 cm). Command the left wheel ωL = 10 rad/s and the right wheel ωR = 14 rad/s. Forward speed:

v = 0.05·(14 + 10)/2 = 0.05·24/2 = 0.05·12 = 0.60 m/s

Turn rate:

ω = 0.05·(14 − 10)/0.30 = 0.05·4/0.30 = 0.20/0.30 ≈ 0.667 rad/s

So these wheel speeds produce a unicycle command of (v, ω) ≈ (0.60 m/s, 0.667 rad/s) — driving forward while curving left, because the right wheel is the faster one. Feed that (v, ω) straight into the Chapter 7 unicycle integrator and you've simulated the differential-drive robot. The two models compose perfectly: wheels → (v, ω) → pose.

The mapping inverts too: given a desired (v, ω) from a controller, solve for the wheel speeds. Adding and subtracting the two equations gives ωR = (v + ωL/2)/r and ωL = (v − ωL/2)/r — which is what actually gets sent to the motors. Check it: with v = 0.60, ω = 0.667, L = 0.30, r = 0.05: ωR = (0.60 + 0.667·0.15)/0.05 = (0.60 + 0.10)/0.05 = 14, ωL = (0.60 − 0.10)/0.05 = 10. We recover the original wheel speeds. The dictionary works in both directions.

The simple car (bicycle model)

A car can't pivot in place — it steers. The simple car (or bicycle model) has rear wheels that drive at speed v and front wheels that steer by an angle φ (the Greek letter phi). With wheelbase L (front-to-rear distance this time), the heading changes at:

ẋ = v cosθ ,   ẏ = v sinθ ,   θ̇ = (v / L) tanφ

Same ẋ, ẏ as the unicycle — it's still a no-slip ground vehicle — but now the turn rate θ̇ is set by the steering angle φ, not commanded directly. So the car's "ω" is ω = (v/L)tanφ. Two consequences fall out immediately and define why cars are harder to plan for than diff-drives:

Dubins and Reeds-Shepp, in one line each. Because the simple car has a minimum turning radius, the shortest path between two poses is not a straight line. A Dubins car (forward-only) has shortest paths made of just three pieces: turn-hard / go-straight / turn-hard (sequences like RSL, LSR). A Reeds-Shepp car (allowed to reverse) adds backing up, giving the cusped maneuvers a parallel-parking human uses. These are the geometric primitives the planners in Lessons 3–5 stitch together — and they exist only because of the nonholonomic, bounded-curvature kinematics you just derived.
Wheel-speed mixer: (ωL, ωR) → (v, ω) → motion

Set the left and right wheel speeds. The widget computes v (average) and ω (difference) live, then drives the differential-drive robot with the resulting unicycle command. Equal speeds → straight; right faster → left curve; opposite → pivot in place. The two wheel bars show their relative speeds.

ωL 6.0 ωR 10.0
v=0.60 ω=0.67
Common confusion: wheelbase L for diff-drive vs. car. In the differential-drive formulas, L is the side-to-side distance between the two driven wheels (the track width). In the bicycle model, L is the front-to-back distance between the steered and driven axles. Same letter, different geometry — plug the right length into the right model or your turn rate will be off. Always sketch the robot and label L before trusting a formula.
python
import numpy as np

# Differential drive: wheel speeds -> unicycle command (v, omega).
def diffdrive(wL, wR, r=0.05, L=0.30):
    v = r*(wR + wL)/2.0      # forward speed = average
    w = r*(wR - wL)/L        # turn rate = scaled difference
    return v, w

print(diffdrive(10, 14))     # (0.6, 0.667)  forward + left curve

# Inverse: desired (v, w) -> wheel speeds the motors must spin.
def inverse_diffdrive(v, w, r=0.05, L=0.30):
    wR = (v + w*L/2)/r
    wL = (v - w*L/2)/r
    return wL, wR
print(np.round(inverse_diffdrive(0.6, 0.667), 2))  # [10. 14.]  recovered

# Simple car (bicycle): steering angle phi sets the turn rate.
def car_step(xi, v, phi, dt, L=2.5):
    x, y, th = xi
    x  = x  + v*np.cos(th)*dt
    y  = y  + v*np.sin(th)*dt
    th = th + (v/L)*np.tan(phi)*dt   # theta-dot = (v/L) tan(phi)
    return np.array([x, y, th])       # at v=0, theta never changes
In the simple-car (bicycle) model θ̇ = (v/L)tanφ, why can a car NOT turn while standing still?

Chapter 9: Showcase — Drive a Robot, Trace a Trajectory

This is the payoff. Everything from the last eight chapters is wired into one simulator: a robot whose pose ξ = (x, y, θ) you drive in real time. You command it two ways — directly as a unicycle (v, ω), or as a differential drive (ωL, ωR) that gets mapped to (v, ω) using Chapter 8's formulas. Either way the same forward-Euler integrator from Chapter 7 advances the configuration and traces the path.

Try the three signature moves and watch the kinematics produce them:

The radius is hiding in the ratio. A robot with constant (v, ω) traces a circle whose radius is exactly R = v/ω. Drive fast and turn slowly (big v/ω) for a gentle sweeping arc; drive slowly and turn hard (small v/ω) for a tight spin. At ω = 0 the radius is infinite — a straight line. Watching v/ω control the circle size on screen is the clearest possible picture of what the two control inputs mean.
Live kinematic simulator — you have the controls

Pick a control mode. In unicycle mode the sliders are (v, ω); in diff-drive mode they are (ωL, ωR) and the panel shows the derived (v, ω). Press drive to integrate and trace the path. Try: set ω (or the wheel difference) so the robot draws a closed circle, then make it a straight line, then a pivot. The readout shows the live ξ and the instantaneous turning radius v/ω.

v 1.5 ω 0.75
ξ = (0.0, 0.0, 0°) v=1.5 ω=0.75 R=2.0
You just closed the body-to-world loop. The slider sets a body-frame intent (v, ω or wheel speeds). The unicycle equations project it through the heading θ into world-frame velocity (ẋ, ẏ). Forward-Euler accumulates that into a world-frame trajectory ξ(t) — the dashed trail. That is exactly the Control-module computation a real robot runs to answer "if I command this, where do I end up?" Lesson 6 adds the feedback that chooses the command to track a goal.
A robot driving with constant v and constant ω ≠ 0 traces a circle. What is its radius?

Chapter 10: Into 3D & the Series Map — SE(3) Preview, Connections, Cheat-Sheet

Everything so far lived on a flat floor: one rotation angle, two translation components, the group SE(2). The instant a robot leaves the ground — a drone, a manipulator arm, a camera tilting to look up — the same ideas scale up, and the bookkeeping grows.

What changes in 3D

2D vs. 3D pose — the dimensions teaser

Left: a planar (SE(2)) frame — 3 numbers, one rotation. Right: a 3D (SE(3)) frame — 6 numbers, three rotation axes drawn (red x, green y, blue z). Same machinery, one more dimension of freedom. Drag the slider to spin the 3D frame's yaw.

yaw 30°

Cheat-sheet — every formula in this lesson

Rotation SO(2): R(θ) = [cosθ, −sinθ ; sinθ, cosθ]. Orthogonal, det = 1, R−1 = R = R(−θ).

Rigid transform SE(2): T = [R(θ), t ; 0, 0, 1] on homogeneous points (x, y, 1). Compose by multiply: TWC = TWB·TBC (inner subscripts match).

Configuration: ξ = (x, y, θ); trajectory ξ(t); velocity ξ̇ = (ẋ, ẏ, θ̇).

Pfaffian constraint: A(ξ)·ξ̇ = 0 — velocity perpendicular to each row. Pendulum: x·ẋ + y·ẏ = 0 (holonomic). No-slip: ẋ sinθ − ẏ cosθ = 0 (nonholonomic).

Unicycle: ẋ = v cosθ, ẏ = v sinθ, θ̇ = ω. Constant (v, ω) → circle of radius v/ω.

Differential drive: v = r(ωRL)/2, ω = r(ωR−ωL)/L.

Simple car (bicycle): θ̇ = (v/L)tanφ. No turning at v = 0; min radius Rmin = L/tanφmax. Dubins (forward) / Reeds-Shepp (with reverse) give shortest paths.

Where this sits on the autonomy stack

ModuleLessonsWhat you'll build
Control & kinematics2 (this), 6SE(2)/SE(3) transforms, unicycle/diff-drive/car models, PID & LQR controllers
Planning3, 4, 5A* on a C-space grid, RRT/RRT*, trajectory optimization — all built on these kinematics
Perception7, 8, 9, 10cameras & calibration use the very same SE(2)/SE(3) frame transforms
Localization & SLAM11, 12, 13, 14pose estimation is estimating ξ over time; the motion model is the unicycle
Executive1, 15coordinates all of the above

Related lessons on Engineermaxxing

"What I cannot create, I do not understand." — Richard Feynman.
You can now turn "go forward, then turn" into wheel speeds, and predict exactly where the robot ends up. Next, Lesson 3: motion planning — choosing which trajectory to drive through these kinematics.
Going from planar SE(2) to spatial SE(3), how many numbers describe a full pose, and what replaces the single rotation angle?