Probabilistic Robotics
Chapter 03PART IFoundationsDifficulty: IntermediateEstimated reading time: 55 min

The Geometry of Motion

Frames, rotations, SE(2), and the exponential map — why a pose is not a vector, and how ⊞ and ⊟ let every filter in this book pretend that it is.

Any rigid-body configuration can be achieved by starting from the fixed (home) reference frame and integrating a constant twist for a specified time.
Kevin M. Lynch and Frank C. ParkModern Robotics, Chapter 3

In this chapter

Probabilistic robotics puts distributions on poses. A pose is not a vector, and almost every frustrating bug in a student SLAM system comes from forgetting it: the heading that averages to zero when it should average to 180°, the covariance ellipse drawn on a quantity that does not live in a plane, the trajectory that drifts because someone integrated a turn as if it were a straight line.

This chapter builds the substrate honestly. Frames and rotation matrices, homogeneous transforms and the cancellation rule, the exponential map from the rotation ODE, SE(2) worked to the last decimal — and then the move that the classical robotics texts do not make, because they never put a distribution on anything: the retraction operators \bplus and \bminus, which turn a curved manifold into a local vector space that a Kalman filter can work in without lying.

It also forges the book's most reused artifact. The SE2 type built here is imported by every filter, every motion model, and every SLAM system from Chapter 4 to Chapter 26. Get it right once.

The average of 179° and −179°

Rusty has two heading estimates. One says 179°, the other says −179°. They disagree by two degrees. What is the average heading?

Add and halve: (179+(179))/2=0(179 + (-179))/2 = 0. The robot is now facing due east, ninety degrees off from where either estimate said, having averaged two nearly identical opinions into their exact opposite. Nothing was noisy, nothing was miscalibrated, and no floating-point rounding was involved. The arithmetic was simply applied to objects that do not support it.

The failure is not an edge case to be patched with an if statement. It is structural: headings live on a circle, and a circle has no consistent notion of "add these two numbers." Every quantity in this book that involves an orientation — a pose, a rotation, a relative measurement between two poses — has the same property, and every operation a filter performs on such a quantity has to be defined rather than assumed.

The repair visible in the purple needle is the entire chapter compressed into one line. Do not add the angles. Instead:

  1. Ask what rotation takes A to B. That is a difference, written \bminus, and on a circle it is the short way around: θBθA=2°\theta_B \bminus \theta_A = 2°, not 358°-358°.
  2. Halve that rotation. Rotations can be scaled — they live in a genuine vector space, the tangent space.
  3. Apply the halved rotation to A. That is \bplus, and the result is 180°180°.

Subtract on the manifold, work in the tangent space, come back to the manifold. Chapters 7, 9, 15, and 16 are all applications of that sentence to progressively harder problems.

Building intuition: frames, and the notation that types them

Before rotations there are frames. A frame is a choice of origin and orthonormal axes; a point in physical space has one position and as many coordinate triples as there are frames looking at it. Craig's notation makes the bookkeeping explicit by decorating every symbol with the frame it is expressed in:

Notation used in this chapter
SymbolMeaning
{A}, {B}\{A\},\ \{B\}Coordinate frames. Rusty carries a body frame {B}; the world frame {W} is bolted to the map.
Ap{}^{A}pThe point p, expressed in the coordinates of {A}. The point does not change; the triple does.
BATSE(2){}^{A}_{B}T \in \SEtwoThe transform "A from B": it maps ᴮp to ᴬp, and equivalently describes the pose of {B} as seen from {A}.
BARSO(2){}^{A}_{B}R \in \SOtwoIts rotation block. The columns are {B}'s axes written in {A}.
τ=(vx,vy,ω)T\boldsymbol{\tau} = (v_x, v_y, \omega)^\mathsf{T}Tangent (twist) coordinates for SE(2), translation first. Note the ordering — this book follows Solà, not Lynch–Park.
τ, ()\tau^{\wedge},\ (\cdot)^{\vee}Hat maps tangent coordinates to the 3×3 Lie-algebra matrix; vee undoes it.
AdT\Ad_TThe adjoint of T: the 3×3 matrix that moves a twist from one frame to another.
xδ, yxx \bplus \delta,\ y \bminus xRetraction x·exp(δ^) and its local inverse log(x⁻¹y)^∨. Right/local convention, fixed book-wide.
qS3q \in S^3A unit quaternion; the double cover of SO(3).

Read every transform name left to right as target-from-source. Then BAT{}^{A}_{B}T consumes something expressed in {B}\{B\} and produces something expressed in {A}\{A\}, and the rule for chaining transforms writes itself: adjacent indices must match, and they cancel.

Two features of that widget deserve to be stated as mathematics rather than left as observations.

Derivation: rotation matrices are stacked axes

DerivationThe columns of a rotation matrix are the frame's own axes

Step 1 — expand the point in {B}\{B\}'s basis. Write Bp=(p1,p2)T{}^{B}p = (p_1, p_2)\T, which means p=p1x^B+p2y^Bp = p_1\,\hat{x}_B + p_2\,\hat{y}_B as a geometric statement about arrows, independent of any frame.

Step 2 — express that statement in {A}\{A\}. Taking coordinates is linear, so

Ap  =  p1Ax^B  +  p2Ay^B  =  [Ax^B    Ay^B]BARBp.{}^{A}p \;=\; p_1\, {}^{A}\hat{x}_B \;+\; p_2\, {}^{A}\hat{y}_B \;=\; \underbrace{\big[\, {}^{A}\hat{x}_B \;\; {}^{A}\hat{y}_B \,\big]}_{{}^{A}_{B}R}\, {}^{B}p .

So the columns of BAR{}^{A}_{B}R are literally {B}\{B\}'s axes, written in {A}\{A\}'s coordinates. That is worth remembering when you are staring at four numbers in a debugger: the first column is where the body's nose points.

Step 3 — read off the constraints. The axes are unit length and mutually perpendicular, and the (i,j)(i,j) entry of RTRR\T R is the dot product of columns ii and jj. Hence RTR=IR\T R = I, so R1=RTR^{-1} = R\T. Taking determinants gives (detR)2=1(\det R)^2 = 1; a right-handed frame maps to a right-handed frame, which selects detR=+1\det R = +1 and excludes reflections.

The set of such matrices is the special orthogonal group

SO(2)={RR2×2:RTR=I, detR=+1},\SOtwo = \{\, R \in \R^{2\times 2} : R\T R = I,\ \det R = +1 \,\},

and SO(3)\SOthree is defined identically with 3×33\times 3 matrices. "Special" is the det=+1\det = +1; "orthogonal" is RTR=IR\T R = I. Both are groups under matrix multiplication: closed, associative, with identity II and inverse RTR\T.

A remark on reading a rotation two ways (Craig §2.2 vs §2.3). The same matrix BAR{}^{A}_{B}R can be read as a mapping — take coordinates in {B}\{B\}, return coordinates in {A}\{A\} — or as an operator — take a vector in {A}\{A\} and rotate it. The numbers are identical; the intent is not. Confusing the two is the single most common sign error in robotics code, and it is exactly what the frame-annotated types in the Practical section are designed to prevent.

Derivation: the cancellation rule

A homogeneous transform packages rotation and translation into one matrix that composes by multiplication:

BAT  =  [BARApB01]SE(2),[Ap1]=BAT[Bp1].{}^{A}_{B}T \;=\; \begin{bmatrix} {}^{A}_{B}R & {}^{A}p_{B} \\ 0 & 1 \end{bmatrix} \in \SEtwo , \qquad \begin{bmatrix} {}^{A}p \\ 1 \end{bmatrix} = {}^{A}_{B}T \begin{bmatrix} {}^{B}p \\ 1 \end{bmatrix}.
DerivationComposition, inversion, and why the indices cancel

Step 1 — compose the mappings. Apply CBT{}^{B}_{C}T to a point expressed in {C}\{C\}, then apply BAT{}^{A}_{B}T to the result:

Ap=BAT(CBTCp)=(BATCBT)Cp.{}^{A}p = {}^{A}_{B}T \left( {}^{B}_{C}T \, {}^{C}p \right) = \left( {}^{A}_{B}T \, {}^{B}_{C}T \right) {}^{C}p .

Associativity of matrix multiplication is what lets us drop the parentheses, and the bracketed product must therefore be CAT{}^{A}_{C}T.

Step 2 — read the index pattern. In BATCBT{}^{A}_{B}T\,{}^{B}_{C}T the inner indices are both BB and they cancel, leaving CAT{}^{A}_{C}T. The expression BATLAT{}^{A}_{B}T\,{}^{A}_{L}T has inner indices BB and AA; it does not cancel, and it is meaningless. This is a type check performed with a pencil, and the Practical section hands it to rustc.

Step 3 — invert by blocks. Guess [RTRTt01]\begin{bmatrix} R\T & -R\T t \\ 0 & 1\end{bmatrix} and multiply:

[Rt01][RTRTt01]=[RRTRRTt+t01]=[I001].  \begin{bmatrix} R & t \\ 0 & 1\end{bmatrix} \begin{bmatrix} R\T & -R\T t \\ 0 & 1\end{bmatrix} = \begin{bmatrix} RR\T & -RR\T t + t \\ 0 & 1\end{bmatrix} = \begin{bmatrix} I & 0 \\ 0 & 1\end{bmatrix}. \;\blacksquare

Note what the inverse is not: it is not (R,t)(-R, -t), and it is not (RT,t)(R\T, -t). The translation must be rotated back into the other frame before it is negated, because it was measured in the frame you are leaving.

Transform equations. Because inverses are available, chains can be solved rather than only evaluated. If a calibration rig tells you BWT{}^{W}_{B}T and LWT{}^{W}_{L}T and you want the sensor extrinsics LBT{}^{B}_{L}T, write BWTLBT=LWT{}^{W}_{B}T\,{}^{B}_{L}T = {}^{W}_{L}T and left-multiply by the inverse: LBT=(BWT)1LWT{}^{B}_{L}T = ({}^{W}_{B}T)^{-1}\,{}^{W}_{L}T. That pattern — unknown = inverse of what you have, times what you want — is Craig's transform-equation technique, and it reappears in Chapter 16 as the definition of a loop-closure constraint.

A worked composition, checkable in your head. Put Rusty at BWT=(2,1,90°){}^{W}_{B}T = (2, 1, 90°) and mount the lidar at LBT=(0.3,0,0){}^{B}_{L}T = (0.3, 0, 0) — thirty centimetres forward on the robot's nose. Then

LWT=BWTLBT=(2+cos90°0.3,  1+sin90°0.3,  90°)=(2,  1.3,  90°).{}^{W}_{L}T = {}^{W}_{B}T\,{}^{B}_{L}T = \big(2 + \cos 90°\cdot 0.3,\; 1 + \sin 90°\cdot 0.3,\; 90°\big) = (2,\; 1.3,\; 90°).

The lidar sits 30 cm north of the robot, not east, because "forward" is a body-frame word. A landmark the lidar reports at Lp=(1,0){}^{L}p = (1, 0) is therefore at Wp=(20,  1.3+1)=(2,  2.3){}^{W}p = (2 - 0,\; 1.3 + 1) = (2,\; 2.3) in the map — a metre "ahead" of the sensor is a metre north in the world, because the whole chain was rotated. The widget above runs the same product with a yawed mount, so its numbers differ; the arithmetic does not.

The mathematics

Rotations in three dimensions, and the traps

SO(3)\SOthree is SO(2)\SOtwo's harder sibling. Three facts make it harder, and all three cost real robots real accuracy.

It is three-dimensional but embedded in nine numbers. A 3×33\times3 matrix has nine entries constrained by six equations (RTR=IR\T R = I is symmetric), leaving three degrees of freedom. Numerical drift pushes a matrix off that constraint surface, so long-running code must re-orthonormalize.

It is not commutative. R1R2R2R1R_1 R_2 \ne R_2 R_1 in general. Rotate this page 90° about the vertical axis, then 90° about the horizontal one; reverse the order; the page ends up somewhere else. The Frame Composer above shows the same fact in the plane by applying one increment about the body frame and about the world frame.

Every minimal parameterization is singular somewhere. Three-parameter representations of SO(3)\SOthree — roll-pitch-yaw and its twenty-three siblings — all have configurations where two of the three axes align and one degree of freedom silently disappears.

Euler angles, in one box. There are twelve valid axis sequences (ZYX, ZYZ, …) and each can be interpreted about fixed or current axes, giving twenty-four conventions that all get called "roll, pitch, yaw". They agree at zero and disagree everywhere else. Every one of them has a gimbal-lock singularity. This book uses Euler angles for exactly one purpose — printing a heading for a human to read — and never for math, storage, or interpolation. When you must interoperate with a system that uses them, write down the convention in the type name, not in a comment.

Unit quaternions are the standard repair: four numbers, one constraint, no singularities.

DerivationUnit quaternions in one honest page

Step 1 — the rotation action. Identify a point pR3p \in \R^3 with the pure quaternion (0,p)(0, p). For a unit quaternion qq, the map pqpqp \mapsto q\,p\,q^{*} (with qq^{*} the conjugate) sends pure quaternions to pure quaternions and preserves norms, so it is an isometry of R3\R^3 fixing the origin. Writing q=(cosθ2, ω^sinθ2)q = (\cos\frac{\theta}{2},\ \hat\omega \sin\frac{\theta}{2}) and expanding the product recovers Rodrigues' formula for a rotation by θ\theta about ω^\hat\omega.

Step 2 — composition is multiplication. q1(q2pq2)q1=(q1q2)p(q1q2)q_1(q_2 p q_2^{*})q_1^{*} = (q_1q_2)p(q_1q_2)^{*}, so composing rotations is multiplying quaternions: sixteen multiplies instead of twenty-seven, and no orthogonality constraint to maintain — only q=1\norm{q} = 1, restored by a single division.

Step 3 — the double cover. (q)p(q)=qpq(-q)\,p\,(-q)^{*} = q\,p\,q^{*}, so qq and q-q name the same rotation. S3S^3 covers SO(3)\SOthree twice. Consequences that bite in practice: the "distance" between two quaternions must be taken to the nearer of ±q2\pm q_2; naively averaging a set of quaternions can cancel them to zero; and interpolation must use slerp, which moves along the great circle at constant angular rate, rather than a componentwise blend followed by renormalization.

We derive quaternions here and then delegate them. nalgebra's UnitQuaternion and sophus's SE(3)\SEthree are correct, tested, and fast; re-implementing them teaches nothing that the two-dimensional case does not teach more clearly.

The exponential map

Here is the question that unifies everything. A rigid body moves with constant velocity for one unit of time. Where does it end up?

DerivationFrom the rotation ODE to the exponential map

Step 1 — differentiate the orthogonality constraint. R(t)TR(t)=IR(t)\T R(t) = I holds for all tt. Differentiating,

R˙TR+RTR˙=0(RTR˙)T=(RTR˙).\dot{R}\T R + R\T \dot{R} = 0 \quad\Longrightarrow\quad (R\T \dot{R})\T = -(R\T \dot{R}).

So RTR˙R\T\dot R is skew-symmetric. Name it [ω]×[\omega]_\times; in the plane that is ωS\omega S with S=[0110]S = \begin{bmatrix} 0 & -1 \\ 1 & 0\end{bmatrix}, and in three dimensions it is the familiar cross-product matrix. The angular velocity is not an extra modelling assumption — it is forced by orthogonality.

Step 2 — solve the linear matrix ODE. R˙=R[ω]×\dot R = R\,[\omega]_\times with ω\omega constant is solved by the matrix exponential, R(t)=R(0)exp([ω]×t)R(t) = R(0)\exp([\omega]_\times t), exactly as the scalar equation r˙=ra\dot r = r a is solved by r0eatr_0 e^{at}.

Step 3 — collapse the series in 2-D. S2=IS^2 = -I, so the series splits into the cosine and sine series:

exp(ωS)=k0(ωS)kk!=Icosω+Ssinω=[cosωsinωsinωcosω].\exp(\omega S) = \sum_{k\ge 0}\frac{(\omega S)^k}{k!} = I\cos\omega + S\sin\omega = \begin{bmatrix}\cos\omega & -\sin\omega\\ \sin\omega & \cos\omega\end{bmatrix}.

The exponential of a skew matrix is a rotation matrix. In two dimensions everything commutes and this is exact and boring, which is precisely why SE(2)\SEtwo is the right place to learn.

Step 4 — collapse the series in 3-D. With ω^\hat\omega a unit axis, [ω^]×3=[ω^]×[\hat\omega]_\times^3 = -[\hat\omega]_\times, so all powers reduce to [ω^]×[\hat\omega]_\times and [ω^]×2[\hat\omega]_\times^2, and

exp ⁣([ω^]×θ)=I+sinθ[ω^]×+(1cosθ)[ω^]×2,\exp\!\big([\hat\omega]_\times\theta\big) = I + \sin\theta\,[\hat\omega]_\times + (1-\cos\theta)\,[\hat\omega]_\times^2 ,

which is Rodrigues' formula. The three numbers ω^θ\hat\omega\theta are the exponential coordinates of the rotation.

Now do the same for a full rigid motion. This is the derivation the rest of the book leans on.

DerivationSE(2) exp and log in closed form

Write the tangent vector as τ=(ρ,ω)\boldsymbol\tau = (\rho, \omega) with ρ=(vx,vy)T\rho = (v_x, v_y)\T the body-frame linear velocity. Its hat form is the 3×33\times3 matrix

τ=[ωSρ00],S=[0110].\tau^{\wedge} = \begin{bmatrix} \omega S & \rho \\ 0 & 0 \end{bmatrix}, \qquad S = \begin{bmatrix} 0 & -1 \\ 1 & 0 \end{bmatrix}.

Step 1 — write the motion as an ODE. A body moving with constant body-frame velocity satisfies T˙=Tτ\dot T = T\,\tau^{\wedge}. Splitting into blocks with T=(R,t)T = (R, t):

R˙=RωS,t˙=Rρ.\dot R = R\,\omega S, \qquad \dot t = R\,\rho .

The second equation is the whole story: the body-frame velocity ρ\rho has to be rotated into the world before it can be integrated, and the rotation is itself changing.

Step 2 — integrate. From Step 1 of the previous derivation, R(s)=R(sω)R(s) = R(s\omega) starting from R(0)=IR(0)=I. Substituting,

t(1)=01R(sω)ρ  ds=(01R(sω)ds)ρ  =:  V(ω)ρ.t(1) = \int_0^1 R(s\omega)\,\rho\;ds = \left(\int_0^1 R(s\omega)\,ds\right)\rho \;=:\; V(\omega)\,\rho .

Step 3 — evaluate the integral entrywise. With 01cos(sω)ds=sinωω\int_0^1 \cos(s\omega)\,ds = \frac{\sin\omega}{\omega} and 01sin(sω)ds=1cosωω\int_0^1 \sin(s\omega)\,ds = \frac{1-\cos\omega}{\omega},

V(ω)=1ω[sinω(1cosω)1cosωsinω],exp(τ)=[R(ω)V(ω)ρ01].V(\omega) = \frac{1}{\omega} \begin{bmatrix} \sin\omega & -(1-\cos\omega) \\ 1-\cos\omega & \sin\omega \end{bmatrix}, \qquad \exp(\tau^{\wedge}) = \begin{bmatrix} R(\omega) & V(\omega)\rho \\ 0 & 1 \end{bmatrix}.

Step 4 — check the straight-line limit. As ω0\omega \to 0, sinωω1\frac{\sin\omega}{\omega}\to 1 and 1cosωω0\frac{1-\cos\omega}{\omega}\to 0, so VIV \to I and exp(τ)(I,ρ)\exp(\tau^{\wedge}) \to (I, \rho): pure translation. The formula degrades gracefully, which is exactly what a robot driving nearly straight needs.

Step 5 — invert. detV=sin2ω+(1cosω)2ω2=2(1cosω)ω2\det V = \frac{\sin^2\omega + (1-\cos\omega)^2}{\omega^2} = \frac{2(1-\cos\omega)}{\omega^2}, so

V(ω)1=[cω/2ω/2c],c=ωsinω2(1cosω)=ω2cotω2,V(\omega)^{-1} = \begin{bmatrix} c & \omega/2 \\ -\omega/2 & c \end{bmatrix}, \qquad c = \frac{\omega\sin\omega}{2(1-\cos\omega)} = \frac{\omega}{2}\cot\frac{\omega}{2},

using 1cosω=2sin2ω21 - \cos\omega = 2\sin^2\frac{\omega}{2} and sinω=2sinω2cosω2\sin\omega = 2\sin\frac{\omega}{2}\cos\frac{\omega}{2}. The logarithm is then

ω=atan2(R21,R11),ρ=V(ω)1t,\omega = \operatorname{atan2}(R_{21}, R_{11}), \qquad \rho = V(\omega)^{-1} t,

defined and smooth for ω<π|\omega| < \pi — and only there, because at ω=π\omega = \pi the rotation by +π+\pi and by π-\pi are the same group element and log\log has to pick one.

Step 6 — the numerical guard. Both VV and V1V^{-1} are 0/00/0 at ω=0\omega = 0, so code needs a branch. Below ω108|\omega| \approx 10^{-8} use the series sinωω=1ω26+\frac{\sin\omega}{\omega} = 1 - \frac{\omega^2}{6} + \cdots, 1cosωω=ω2ω324+\frac{1-\cos\omega}{\omega} = \frac{\omega}{2} - \frac{\omega^3}{24} + \cdots, and c=1ω212c = 1 - \frac{\omega^2}{12} - \cdots. Above it, write 1cosω1-\cos\omega as 2sin2ω22\sin^2\frac{\omega}{2} rather than literally. At the ω103\omega \approx 10^{-3} a 60 Hz control loop produces, 1cosω5×1071 - \cos\omega \approx 5\times10^{-7}, so subtracting two numbers that agree to six places throws away six of the sixteen digits a f64 had — and it gets worse as ω\omega shrinks, right in the regime where a robot spends most of its life. The half-angle form never subtracts anything.

The geometric content of V(ω)V(\omega) is worth saying in words, because it is the payoff. A constant twist does not drive a body along a straight line; it sweeps the body around a fixed point — the instantaneous center of rotation, at (vy/ω, vx/ω)(-v_y/\omega,\ v_x/\omega) in body coordinates — at constant angular rate. This is the planar case of the Chasles–Mozzi theorem, the one Lynch and Park state as a screw: every rigid displacement is a rotation about some axis combined with a translation along it, and in the plane the translation vanishes, leaving a rotation about a point or a pure translation. VV is the bookkeeping that converts "how far I drove along the arc" into "where I ended up".

The adjoint

Twists, like points, are frame-dependent. The object that moves them between frames is the adjoint.

DerivationThe SE(2) adjoint by block computation

Step 1 — conjugate a one-parameter motion. For fixed TT, the curve sTexp(sτ)T1s \mapsto T\exp(s\tau^{\wedge})T^{-1} passes through the identity at s=0s=0 and is a one-parameter subgroup, so it equals exp(sσ)\exp(s\,\sigma^{\wedge}) for some tangent vector σ\sigma. Define AdT\Ad_T by σ=AdTτ\sigma = \Ad_T\,\tau.

Step 2 — differentiate at s=0s = 0. This gives the equivalent linear statement (AdTτ)=TτT1(\Ad_T\tau)^{\wedge} = T\,\tau^{\wedge}\,T^{-1}. Expand with T=(R,t)T = (R, t):

[Rt01][ωSρ00][RTRTt01]=[ωRSRTωRSRTt+Rρ00].\begin{bmatrix} R & t \\ 0 & 1\end{bmatrix} \begin{bmatrix} \omega S & \rho \\ 0 & 0\end{bmatrix} \begin{bmatrix} R\T & -R\T t \\ 0 & 1\end{bmatrix} = \begin{bmatrix} \omega RSR\T & -\omega R S R\T t + R\rho \\ 0 & 0 \end{bmatrix}.

Step 3 — use the two-dimensional accident. S=R(π/2)S = R(\pi/2), and planar rotations commute, so RSRT=SRSR\T = S. The rotation part of the twist is therefore unchanged — in the plane, angular velocity is frame-independent — and the linear part becomes ρ=RρωSt\rho' = R\rho - \omega\,S\,t. Collecting blocks with the translation-first ordering,

AdT=[RSt01]=[cosθsinθtysinθcosθtx001].  \Ad_T = \begin{bmatrix} R & -S\,t \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} \cos\theta & -\sin\theta & t_y \\ \sin\theta & \cos\theta & -t_x \\ 0 & 0 & 1\end{bmatrix}. \;\blacksquare

A note on authority. Sign and ordering conventions for adjoints are notorious; published papers disagree with each other and with themselves. This book's authority is not anyone's memory but the property test adjoint_conjugation in the Practical section, which asserts Texp(τ)T1=exp((AdTτ))T\exp(\tau^{\wedge})T^{-1} = \exp\big((\Ad_T\tau)^{\wedge}\big) on every random input proptest can throw at it. If you change the tangent ordering, that test — not your memory — tells you what the adjoint became.

The adjoint is how a covariance changes frames. If a body-frame uncertainty is Σbody\Sigma_{\text{body}}, the same uncertainty in the world frame is AdTΣbodyAdTT\Ad_T\,\Sigma_{\text{body}}\,\Ad_T\T — the manifold version of the familiar AΣATA\Sigma A\T, and the reason Chapter 9 can compose odometry uncertainty at all.

⊞ and ⊟: a legitimate local vector space

Every estimator in this book wants to write x+δx + \delta: the Kalman update adds a correction to a mean, Gauss–Newton adds a step to an iterate, a particle filter adds noise to a sample. On SE(2)\SEtwo that expression is undefined. The fix is to define it, once, with the two operators already used informally above:

xδ=xexp(δ),yx=log ⁣(x1y).\htmlClass{term-posterior}{x \bplus \delta} = x \cdot \exp(\delta^{\wedge}), \qquad \htmlClass{term-measurement}{y \bminus x} = \log\!\big(x^{-1}y\big)^{\vee}.
Derivation⊞ and ⊟ satisfy the Hertzberg axioms

Axiom 1 — the zero increment does nothing. x0=xexp(0)=xI=xx \bplus 0 = x\cdot\exp(0) = x\cdot I = x.

Axiom 2 — ⊟ finds the increment that ⊞ needs.

x(yx)=xexp ⁣(log(x1y))=xx1y=y,x \bplus (y \bminus x) = x\cdot\exp\!\big(\log(x^{-1}y)\big) = x\cdot x^{-1} y = y,

valid wherever exp\exp and log\log are mutual inverses, which by Step 5 of the SE(2)\SEtwo derivation means ω<π|\omega| < \pi for the relative rotation between xx and yy.

Axiom 3 — ⊟ recovers the increment.

(xδ)x=log ⁣(x1xexp(δ))=δ,(x \bplus \delta)\bminus x = \log\!\big(x^{-1}\,x\exp(\delta^{\wedge})\big)^{\vee} = \delta,

again for δω<π|\delta_\omega| < \pi. Together with smoothness of exp\exp and log\log, these are exactly Hertzberg et al.'s axioms for a manifold encapsulation: they say that near any xx, the map δxδ\delta \mapsto x \bplus \delta is a well-behaved chart, so an algorithm written in δ\delta is doing honest calculus.

Which side? xexp(δ)x\exp(\delta^{\wedge}) is the right (local) convention: the increment is applied in the body frame. exp(δ)x\exp(\delta^{\wedge})x is the left (global) convention, applied in the world frame. They differ by the adjoint, δleft=Adxδright\delta_{\text{left}} = \Ad_x\,\delta_{\text{right}} — which is what the two ghost frames in the Frame Composer are showing. This book fixes the right convention everywhere, for a physical reason: a robot's odometry, its wheel encoders, and its IMU all report body-frame quantities, so right increments are the ones the hardware actually produces. Chapter 7 turns the choice into an error-state EKF; the invariant-EKF literature makes the opposite choice deliberately and gets different, sometimes better, convergence guarantees.

Vectors are manifolds too, with =+\bplus = + and =\bminus = -. That is not a technicality: it means one generic filter implementation covers a Euclidean state, a pose, and a pose-plus-bias state, with the compiler picking the right operators.

The algorithms

Geometry has no Thrun table names, so these are the book's Rust API, stated once and never re-derived.

Algorithmse2_exp(τ) → TCostO(1) — two transcendental calls
In
τ = (v_x, v_y, ω)ᵀ ∈ ℝ³, tangent coordinates
Out
T ∈ SE(2)
  1. if ω<ε|\omega| < \varepsilon then a1ω2/6a \leftarrow 1 - \omega^2/6,   bω/2ω3/24b \leftarrow \omega/2 - \omega^3/24
  2. else asinω/ωa \leftarrow \sin\omega / \omega,   b2sin2(ω/2)/ωb \leftarrow 2\sin^2(\omega/2)\,/\,\omega
  3. endif
  4. RRot(ω)R \leftarrow \operatorname{Rot}(\omega)
  5. t(avxbvy,  bvx+avy)Tt \leftarrow (a v_x - b v_y,\; b v_x + a v_y)\T   // this is V(ω)ρV(\omega)\rho
  6. return (R,t)(R, t)
Algorithmse2_log(T) → τCostO(1); exact inverse of se2_exp on |ω| < π
In
T = (R, t) ∈ SE(2)
Out
τ ∈ ℝ³, with |ω| < π
  1. ωatan2(R21,R11)\omega \leftarrow \operatorname{atan2}(R_{21}, R_{11})   // already wrapped to (π,π](-\pi, \pi]
  2. hω/2h \leftarrow \omega/2
  3. if ω<ε|\omega| < \varepsilon then c1ω2/12c \leftarrow 1 - \omega^2/12 else chcothc \leftarrow h\,\cot h endif
  4. ρ(ctx+hty,  htx+cty)T\rho \leftarrow (c\,t_x + h\,t_y,\; -h\,t_x + c\,t_y)\T   // this is V(ω)1tV(\omega)^{-1}t
  5. return (ρ,ω)(\rho, \omega)

Everything else is one line on top of these: compose(a,b)=ab\text{compose}(a,b) = ab, inverse(T)=(RT,RTt)\text{inverse}(T) = (R\T, -R\T t), xδ=xse2_exp(δ)x \bplus \delta = x\cdot\texttt{se2\_exp}(\delta), and yx=se2_log(x1y)y \bminus x = \texttt{se2\_log}(x^{-1}y).

Uncertainty on a manifold

A short section now, because it is the bridge to Parts II and III; the full treatment is Chapter 9.

If a pose is not a vector, a distribution over poses is not the Gaussian on R3\R^3 that Chapter 2 built. Writing N((x,y,θ);μ,Σ)\Normal\big((x,y,\theta); \mu, \Sigma\big) is already wrong: it assigns different densities to the same rotation depending on which branch of θ\theta you wrote down. The standard repair is a concentrated Gaussian: put an ordinary Gaussian in the tangent space, where it is at home, and push it onto the group through the retraction,

x=xˉδ,δN(0,Σ),ΣR3×3.x = \htmlClass{term-truth}{\bar{x}} \bplus \boldsymbol\delta, \qquad \boldsymbol\delta \sim \Normal(0, \Sigma), \quad \Sigma \in \R^{3\times3} .

The mean xˉ\bar x lives on the manifold, the covariance lives in the tangent space at xˉ\bar x, and every operation a filter performs happens in that tangent space. What the construction produces in (x,y)(x, y) coordinates is not an ellipse.

Two consequences, both of which get proper treatment later. First, E[exp(τ)]exp(E[τ])\E[\exp(\tau)] \ne \exp(\E[\tau]): the sample mean of the cloud is not the commanded endpoint, and the gap grows with σω\sigma_\omega and with distance travelled. Second, an ellipse fitted to the cloud claims mass in places the robot cannot be. A Gaussian filter tracking a pose is making a bet that the banana is thin enough to pass for an ellipse over one time step — which is usually true at 60 Hz and disastrously false over a ten-metre dead-reckoned stretch. Chapter 7 names that bet and prices it.

Implementation in Rust

The type is small enough to read in one sitting and central enough that it is worth reading carefully. Two design decisions are load-bearing. The rotation is stored as a UnitComplex, not as an f64 angle, so no arithmetic can ever produce an unwrapped heading. And the tangent type is a fixed-size SVector<f64, 3>, so a dimension mismatch is a compile error rather than a panic.

crates/pr-core/src/geom/se2.rs
use nalgebra::{Point2, SMatrix, SVector, UnitComplex, Vector2};

/// Tangent coordinates τ = (vₓ, v_y, ω)ᵀ — **translation first** (Solà order).
///
/// Lynch & Park write (ω, v). We do not, because the reader already holds the
/// pose tuple (x, y, θ) and because `factrs` and `sophus` order it this way.
pub type Tangent2 = SVector<f64, 3>;

/// Below this |ω| the closed forms are 0/0 and we switch to Taylor series.
const SMALL_ANGLE: f64 = 1e-8;

/// A planar rigid-body transform: the book's most-used type.
///
/// Storing the rotation as a `UnitComplex` rather than an angle is not a
/// micro-optimization — it makes an unwrapped heading *unrepresentable*.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SE2 {
    pub rot: UnitComplex<f64>,
    pub trans: Vector2<f64>,
}

impl SE2 {
    pub fn identity() -> Self {
        Self { rot: UnitComplex::identity(), trans: Vector2::zeros() }
    }

    pub fn new(x: f64, y: f64, theta: f64) -> Self {
        Self { rot: UnitComplex::new(theta), trans: Vector2::new(x, y) }
    }

    /// exp: 𝔰𝔢(2) → SE(2). Derivation 4, with the guard from its Step 6.
    pub fn exp(tau: &Tangent2) -> Self {
        let (vx, vy, w) = (tau[0], tau[1], tau[2]);
        let (a, b) = if w.abs() < SMALL_ANGLE {
            (1.0 - w * w / 6.0, w / 2.0 - w * w * w / 24.0)
        } else {
            // (1 − cos ω) written as 2 sin²(ω/2): the literal form loses about
            // eight digits to cancellation at the ω a 60 Hz loop produces.
            let sh = (0.5 * w).sin();
            (w.sin() / w, 2.0 * sh * sh / w)
        };
        Self {
            rot: UnitComplex::new(w),
            trans: Vector2::new(a * vx - b * vy, b * vx + a * vy),
        }
    }

    /// log: SE(2) → 𝔰𝔢(2). Exact inverse of `exp` for |ω| < π.
    pub fn log(&self) -> Tangent2 {
        let w = self.rot.angle(); // UnitComplex::angle is already in (−π, π]
        let h = 0.5 * w;
        // c = (ω/2)·cot(ω/2), the cancellation-free form of ω sin ω / 2(1−cos ω).
        let c = if w.abs() < SMALL_ANGLE { 1.0 - w * w / 12.0 } else { h / h.tan() };
        let (x, y) = (self.trans.x, self.trans.y);
        Tangent2::new(c * x + h * y, -h * x + c * y, w)
    }

    /// (Rᵀ, −Rᵀt). Note that the translation is rotated *before* it is negated.
    pub fn inverse(&self) -> Self {
        let r_inv = self.rot.inverse();
        Self { rot: r_inv, trans: -(r_inv * self.trans) }
    }

    /// Ad_T, satisfying T·exp(τ)·T⁻¹ = exp(Ad_T τ). Derivation 6.
    pub fn adjoint(&self) -> SMatrix<f64, 3, 3> {
        let r = self.rot.to_rotation_matrix();
        let m = r.matrix();
        SMatrix::<f64, 3, 3>::new(
            m[(0, 0)], m[(0, 1)],  self.trans.y,
            m[(1, 0)], m[(1, 1)], -self.trans.x,
            0.0,       0.0,        1.0,
        )
    }

    /// Map a point from this frame's coordinates into the parent frame.
    pub fn act(&self, p: &Point2<f64>) -> Point2<f64> {
        self.rot.transform_point(p) + self.trans
    }

    /// Display only. Never do arithmetic on the tuple — that is what ⊞ is for.
    pub fn xytheta(&self) -> (f64, f64, f64) {
        (self.trans.x, self.trans.y, self.rot.angle())
    }
}

impl std::ops::Mul for SE2 {
    type Output = SE2;
    fn mul(self, rhs: SE2) -> SE2 {
        SE2 { rot: self.rot * rhs.rot, trans: self.trans + self.rot * rhs.trans }
    }
}

The retraction goes in its own trait, because that is the interface every later estimator is generic over. D is the tangent dimension, and it is a const generic so that the compiler knows the size of every Jacobian in Chapter 7.

crates/pr-core/src/geom/manifold.rs
use nalgebra::SVector;
use crate::geom::se2::{Tangent2, SE2};

/// The interface the book's filters are written against.
///
/// `x ⊞ δ` moves `x` by a tangent-space increment; `y ⊟ x` recovers the
/// increment that would do it. Implementations must satisfy the Hertzberg
/// axioms, which `tests/geom.rs` checks by property test rather than by trust.
pub trait Manifold<const D: usize>: Copy {
    fn boxplus(&self, delta: &SVector<f64, D>) -> Self;
    fn boxminus(&self, other: &Self) -> SVector<f64, D>;
}

impl Manifold<3> for SE2 {
    /// Right (local) convention: the increment is applied in the body frame.
    fn boxplus(&self, delta: &Tangent2) -> Self {
        *self * SE2::exp(delta)
    }
    fn boxminus(&self, other: &Self) -> Tangent2 {
        (other.inverse() * *self).log()
    }
}

/// Vectors are manifolds too — flat ones. This impl is why one generic EKF
/// serves both a Euclidean state and a pose without a line of special-casing.
impl<const N: usize> Manifold<N> for SVector<f64, N> {
    fn boxplus(&self, delta: &SVector<f64, N>) -> Self { self + delta }
    fn boxminus(&self, other: &Self) -> SVector<f64, N> { self - other }
}

Craig's notation, enforced by rustc

Craig invented a type system in 1986 and wrote it in superscripts. Rust can check it. The markers are zero-sized, PhantomData occupies nothing, and the whole thing compiles down to the same machine code as bare SE2.

crates/pr-core/src/geom/frames.rs
use core::marker::PhantomData;
use core::ops::Mul;
use crate::geom::se2::SE2;

pub trait Frame: 'static {}

pub struct World;
pub struct Body;
pub struct LidarF;
impl Frame for World {}
impl Frame for Body {}
impl Frame for LidarF {}

/// `Pose<A, B>` is Craig's ᴬ_BT: "A from B". Zero runtime cost, and the index
/// bookkeeping becomes the compiler's problem instead of yours.
#[derive(Clone, Copy, Debug)]
pub struct Pose<A: Frame, B: Frame>(pub SE2, PhantomData<fn() -> (A, B)>);

impl<A: Frame, B: Frame> Pose<A, B> {
    pub fn new(t: SE2) -> Self { Self(t, PhantomData) }
    pub fn inverse(self) -> Pose<B, A> { Pose::new(self.0.inverse()) }
}

/// The cancellation rule, as a trait impl: the inner frames must agree, and
/// they vanish from the output type.
impl<A: Frame, B: Frame, C: Frame> Mul<Pose<B, C>> for Pose<A, B> {
    type Output = Pose<A, C>;
    fn mul(self, rhs: Pose<B, C>) -> Pose<A, C> { Pose::new(self.0 * rhs.0) }
}

Now the payoff. This is a deliberate compile error, and it is a feature of the chapter:

demos/ch03-geometry/examples/frame_safety.rs
let world_from_body: Pose<World, Body> = Pose::new(SE2::new(2.0, 1.0, FRAC_PI_2));
let world_from_lidar: Pose<World, LidarF> = Pose::new(SE2::new(2.0, 1.3, FRAC_PI_2));

// Wrong: the inner indices are W and W. They do not cancel.
let oops = world_from_body * world_from_lidar;
cargo build
error[E0308]: mismatched types
  --> demos/ch03-geometry/examples/frame_safety.rs:6:31
   |
 6 |     let oops = world_from_body * world_from_lidar;
   |                                  ^^^^^^^^^^^^^^^^ expected `Pose<Body, _>`,
   |                                                   found `Pose<World, LidarF>`

What you meant was world_from_body.inverse() * world_from_lidar, whose type is Pose<Body, LidarF> — the sensor extrinsics. The convention for the rest of the book: bare SE2 for state inside an estimator, where everything is in one frame by construction, and Pose<_, _> at subsystem boundaries — extrinsics, map anchoring, anything crossing a module line.

Putting it together: the square dance

Here is the numeric example to check by hand. It is the smallest computation that exercises the whole chapter, and a test pins it.

Drive a quarter circle. Command τ=(1,0,π2)T\boldsymbol\tau = (1, 0, \tfrac{\pi}{2})\T: one metre of arc while turning ninety degrees. From Derivation 4,

V ⁣(π2)=2π[1111],V ⁣(π2)(10)=2π(11),V\!\left(\tfrac{\pi}{2}\right) = \frac{2}{\pi}\begin{bmatrix} 1 & -1 \\ 1 & 1 \end{bmatrix}, \qquad V\!\left(\tfrac{\pi}{2}\right)\begin{pmatrix}1\\0\end{pmatrix} = \frac{2}{\pi}\begin{pmatrix}1\\1\end{pmatrix},

because sinπ2=1\sin\frac{\pi}{2} = 1 and 1cosπ2=11 - \cos\frac{\pi}{2} = 1, and 1/ω=2/π1/\omega = 2/\pi. So

exp ⁣(τ)=(2π, 2π, π2)(0.636620,  0.636620,  1.570796).\exp\!\big(\tau^{\wedge}\big) = \left(\tfrac{2}{\pi},\ \tfrac{2}{\pi},\ \tfrac{\pi}{2}\right) \approx (0.636620,\; 0.636620,\; 1.570796).

Sanity check the geometry: the turn radius is v/ω=2/π|v|/\omega = 2/\pi, the center of the arc is at (0,2/π)(0, 2/\pi), and rotating the start point 90°90° about that center lands exactly on (2/π,2/π)(2/\pi, 2/\pi).

Go back. log\log of that pose returns ω=π/2\omega = \pi/2, so h=π/4h = \pi/4 and c=π4cotπ4=π4c = \frac{\pi}{4}\cot\frac{\pi}{4} = \frac{\pi}{4} — the two entries of V1V^{-1} are equal, and

ρ=π4[1111]2π(11)=12(20)=(10).\rho = \frac{\pi}{4}\begin{bmatrix}1 & 1\\ -1 & 1\end{bmatrix}\frac{2}{\pi}\begin{pmatrix}1\\1\end{pmatrix} = \frac{1}{2}\begin{pmatrix}2\\0\end{pmatrix} = \begin{pmatrix}1\\0\end{pmatrix}. \checkmark

Compare with Euler integration. The classical update x+=Δscosθx \mathrel{+}= \Delta s\cos\theta, y+=Δssinθy \mathrel{+}= \Delta s\sin\theta, θ+=Δθ\theta \mathrel{+}= \Delta\theta applied in KK equal substeps gives

KKendpointerror vs. exp\exp
1(1.000000, 0.000000)(1.000000,\ 0.000000)0.7330 m
4(0.753417, 0.503417)(0.753417,\ 0.503417)0.1772 m
16≈ 0.044 m

The error falls like 1/K1/K, which is first-order convergence, which is what "Euler" means. The exponential is exact at K=1K = 1, because it is not an approximation of the arc — it is the arc.

Close a square. Alternate exp((1,0,0)) and exp((0,0,π/2)) four times. Rusty returns to the origin, and the closure error measured on the manifold, finalSE2::identity()\norm{\,\text{final} \bminus \mathrm{SE2::identity()}\,}, is at the level of floating-point round-off — around 101610^{-16}, not 10310^{-3}. That is the difference between arithmetic that respects the group and arithmetic that approximates it.

crates/pr-core/tests/geom.rs
use approx::assert_relative_eq;
use nalgebra::Vector3;
use proptest::prelude::*;
use rand::{rngs::SmallRng, Rng, SeedableRng};
use std::f64::consts::{FRAC_PI_2, PI};

use pr_core::geom::{manifold::Manifold, se2::{Tangent2, SE2}};

/// The chapter's worked example, pinned to 1e-12.
#[test]
fn worked_example_ch03_quarter_arc() {
    let tau = Tangent2::new(1.0, 0.0, FRAC_PI_2);
    let t = SE2::exp(&tau);
    let (x, y, theta) = t.xytheta();

    assert_relative_eq!(x, 2.0 / PI, epsilon = 1e-12); // 0.636619772...
    assert_relative_eq!(y, 2.0 / PI, epsilon = 1e-12);
    assert_relative_eq!(theta, FRAC_PI_2, epsilon = 1e-12);

    // …and log takes us straight back.
    assert_relative_eq!(t.log(), tau, epsilon = 1e-12);

    // One Euler step is off by 73 centimetres on a one-metre arc.
    let euler = (1.0 - x).hypot(0.0 - y);
    assert_relative_eq!(euler, 0.733028, epsilon = 1e-6);
}

/// Four sides and four right-angle turns must return exactly to the start.
#[test]
fn square_dance_closes() {
    let forward = SE2::exp(&Tangent2::new(1.0, 0.0, 0.0));
    let turn = SE2::exp(&Tangent2::new(0.0, 0.0, FRAC_PI_2));

    let mut pose = SE2::identity();
    for _ in 0..4 {
        pose = pose * forward * turn;
    }

    // Measured on the manifold, not on the tuple: ‖final ⊟ identity‖.
    let closure = pose.boxminus(&SE2::identity()).norm();
    assert!(closure < 1e-14, "closure error {closure:e} — the group is not closing");
}

fn twist() -> impl Strategy<Value = Tangent2> {
    // |ω| < π keeps us inside log's injectivity radius, per Derivation 4 Step 5.
    (-5.0f64..5.0, -5.0f64..5.0, -3.14f64..3.14)
        .prop_map(|(a, b, c)| Tangent2::new(a, b, c))
}

proptest! {
    #[test]
    fn exp_log_roundtrip(tau in twist()) {
        prop_assert!((SE2::exp(&tau).log() - tau).norm() < 1e-12);
    }

    #[test]
    fn group_axioms(a in twist(), b in twist(), c in twist()) {
        let (ta, tb, tc) = (SE2::exp(&a), SE2::exp(&b), SE2::exp(&c));
        let lhs = (ta * tb) * tc;
        let rhs = ta * (tb * tc);
        prop_assert!(lhs.boxminus(&rhs).norm() < 1e-12);          // associativity
        prop_assert!((ta * ta.inverse()).log().norm() < 1e-12);   // inverse
    }

    /// The authority on the adjoint's sign convention. Not memory — this.
    #[test]
    fn adjoint_conjugation(t in twist(), tau in twist()) {
        let big_t = SE2::exp(&t);
        let lhs = big_t * SE2::exp(&tau) * big_t.inverse();
        let rhs = SE2::exp(&(big_t.adjoint() * tau));
        prop_assert!(lhs.boxminus(&rhs).norm() < 1e-10);
    }

    #[test]
    fn boxplus_axioms(x in twist(), d in twist()) {
        let big_x = SE2::exp(&x);
        prop_assert!(big_x.boxplus(&Tangent2::zeros()).boxminus(&big_x).norm() < 1e-14);
        let y = big_x.boxplus(&d);
        prop_assert!((y.boxminus(&big_x) - d).norm() < 1e-12);
        prop_assert!(big_x.boxplus(&y.boxminus(&big_x)).boxminus(&y).norm() < 1e-12);
    }
}

/// log stays accurate right up to the injectivity boundary — the place where
/// a naive implementation quietly returns the wrong branch.
#[test]
fn log_is_stable_near_pi() {
    let mut rng = SmallRng::seed_from_u64(0xC0FFEE); // never thread_rng() in this book
    for _ in 0..1000 {
        let theta = PI - rng.random_range(1e-9..1e-6);
        let ours = SE2::new(0.3, -0.2, theta).log();
        assert_relative_eq!(ours[2], theta, epsilon = 1e-12);
        assert_relative_eq!(SE2::exp(&ours).log(), ours, epsilon = 1e-9);
    }
}

Every widget on this page runs the TypeScript port of the code above — web/lib/geom/se2.ts and web/lib/geom/screw.ts — and both implementations are checked against this same quarter-arc example. If the prose and the code ever disagree, the test settles it.

The three-dimensional case is delegated, not skipped. sophus provides SO(3)\SOthree and SE(3)\SEthree with the same exp/log/adjoint surface, nalgebra's UnitQuaternion handles the parameterization, and Chapter 18 uses both. The reason to hand-roll SE(2)\SEtwo and only SE(2)\SEtwo is that every structure in the general case is visible here, in two dimensions, where you can check the answer with a protractor.

Exercises

  1. Foundation exerciseDifficulty 2 of 3Derive V and its inverse

    Evaluate V(ω)=01R(sω)dsV(\omega) = \int_0^1 R(s\omega)\,ds entrywise to reproduce Derivation 4's closed form, then derive V(ω)1V(\omega)^{-1} by inverting the 2×22\times2 matrix directly. Show that both tend to II as ω0\omega \to 0, and compute the first two nonzero terms of the Taylor series of each entry — these are the coefficients the code branches to.

  2. Foundation exerciseDifficulty 2 of 3The adjoint is a homomorphism

    Prove the SE(2)\SEtwo adjoint formula of Derivation 6 by block computation, then show that AdT1T2=AdT1AdT2\Ad_{T_1T_2} = \Ad_{T_1}\Ad_{T_2} and AdT1=(AdT)1\Ad_{T^{-1}} = (\Ad_T)^{-1}. Explain why the first identity says "changing frames twice is one change of frame", and why that is exactly what makes covariance propagation across a kinematic chain composable.

  3. Foundation exerciseDifficulty 3 of 3Right increments are body-frame quantities

    Let x=xδx' = x \bplus \delta with δ=(ρ,ωδ)\delta = (\rho, \omega_\delta). Show that the world-frame displacement of the origin is RxV(ωδ)ρR_x V(\omega_\delta)\rho, and hence RxρR_x\rho to first order. Conclude why wheel odometry naturally produces right increments, and derive the conversion δleft=Adxδright\delta_{\text{left}} = \Ad_x \delta_{\text{right}} to the left convention.

  4. Conceptual exerciseDifficulty 2 of 3Predict, then verify with the Exp/Log Lens

    Two poses differ by a pure 180°180° rotation with no translation. Predict, before touching the widget: where does the geodesic interpolation put the robot at s=0.5s = 0.5, and why does log\log fail to pick a unique answer for this pair? Now drag the arrowhead in w3.2 to the origin so v0v \approx 0, and slide ω\omega toward ±3.1\pm 3.1. The slider deliberately stops short of π\pi — say why, in terms of Derivation 4, Step 5, and describe what happens to the arc and to the ICR as you approach the limit from either side.

  5. Conceptual exerciseDifficulty 1 of 3Squeeze the banana

    In w3.4, predict what happens to the cloud as σω0\sigma_\omega \to 0 with σv\sigma_v fixed, and what happens to the 2σ coverage readout. Verify. Then state, in one sentence, the condition under which the fitted ellipse is approximately honest — you have just described the bet the EKF makes in Chapter 7.

  6. Practical exerciseDifficulty 2 of 3Implement SE2 and pass the suite

    Implement SE2 with exp, log, inverse, adjoint, and Mul, plus the Manifold<3> impl, so that every test in crates/pr-core/tests/geom.rs above passes. Then deliberately break the small-angle guard by deleting the Taylor branch, and find the value of ω|\omega| below which exp_log_roundtrip starts failing. Report it, and name the subtraction that lost the digits.

  7. Practical exerciseDifficulty 3 of 3SO3 under the same trait

    Implement SO3 as a newtype over nalgebra::UnitQuaternion<f64> with exp, log, and Manifold<3>. Handle θπ\theta \approx \pi with the numerically stable branch (the naive log\log divides by sin(θ/2)\sin(\theta/2), which vanishes at θ=0\theta = 0, and loses precision as θπ\theta \to \pi from the other side). Property-test the round trip against sophus at a thousand random rotations including the near-π\pi band, and plot the round-trip error against θ\theta.

  8. Practical exerciseDifficulty 2 of 3Make the frame bug uncompilable

    Take a working piece of code that composes transforms with bare SE2 — the widget's lidar chain is a fine target — and port it to Pose<A, B>. Introduce a realistic frame bug (compose the extrinsics in the wrong order) and show the compiler catching it. Then measure: does the newtype cost anything? Compare the generated assembly for Pose<World, Body> * Pose<Body, LidarF> with plain SE2 * SE2.

References

  1. Lynch, K. M. and Park, F. C. (2017) Modern Robotics: Mechanics, Planning, and Control. Cambridge University Press. ISBN 978-1107156302.link to Modern Robotics: Mechanics, Planning, and Control (opens in a new tab)

    Chapter 3 is this chapter's computational spine: planar rigid motions, angular velocity, exponential coordinates, twists, and screws. The epigraph is from its opening pages. Note the tangent ordering differs from ours — they write (ω, v).

  2. Craig, J. J. (2005) Introduction to Robotics: Mechanics and Control. Pearson, 3rd edition. ISBN 978-0201543612.link to Introduction to Robotics: Mechanics and Control (opens in a new tab)

    The source of the leading super/subscript notation and the cancellation rule. Sections 2.2–2.7 are the clearest treatment of frames as a discipline anywhere; the Rust newtypes in this chapter are that discipline, mechanized.

  3. Spong, M. W., Hutchinson, S., and Vidyasagar, M. (2020) Robot Modeling and Control. Wiley, 2nd edition. ISBN 978-1119523994.link to Robot Modeling and Control (opens in a new tab)

    Section 2.4 states the fixed-frame versus current-frame composition rule — pre- versus post-multiplication — which is exactly the two ghost frames in the Frame Composer, and the reason this book fixes the right convention.

  4. Hertzberg, C., Wagner, R., Frese, U., and Schröder, L. (2013) Integrating generic sensor fusion algorithms with sound state representations through encapsulation of manifolds. Information Fusion 14(1), 57–77.doi:10.1016/j.inffus.2011.08.003 (opens in a new tab)

    Where ⊞ and ⊟ come from, with the axioms proved in Derivation 7. The paper's thesis is this chapter's thesis: encapsulate the manifold and every estimator downstream can be written as if the state were a vector.

  5. Solà, J., Deray, J., and Atchuthan, D. (2018) A micro Lie theory for state estimation in robotics. arXiv:1812.01537.link to A micro Lie theory for state estimation in robotics (opens in a new tab)

    The modernization spine of this chapter: the ⊞/⊟ interface, the adjoint, and the Jacobian bookkeeping that Part II needs. This book follows its translation-first tangent ordering and its right-⊞ convention.

  6. Mangelson, J. G., Ghaffari, M., Vasudevan, R., and Eustice, R. M. (2020) Characterizing the Uncertainty of Jointly Distributed Poses in the Lie Algebra. IEEE Transactions on Robotics 36(5), 1371–1388.doi:10.1109/TRO.2020.2994457 (opens in a new tab)

    The banana, taken seriously: what a concentrated Gaussian on SE(2)/SE(3) actually looks like, and why the independence assumption between poses in a graph is usually false. Chapter 9 builds on its compounding operations.

  7. Potokar, E. R., Beard, R. W., and Mangelson, J. G. (2024) An Introduction to the Invariant Extended Kalman Filter [Lecture Notes]. IEEE Control Systems 44(6).doi:10.1109/MCS.2024.3466488 (opens in a new tab)

    The most readable recent account of why the choice between left and right ⊞ has consequences for convergence, not just for bookkeeping. Read it after Chapter 7.

  8. Ge, Y., van Goor, P., and Mahony, R. (2025) The Geometry of Extended Kalman Filters on Manifolds with Affine Connection. arXiv:2506.05728.link to The Geometry of Extended Kalman Filters on Manifolds with Affine Connection (opens in a new tab)

    Where the field is now: the retraction of this chapter is one choice among many, and connection, parallel transport, and curvature all show up in the filter equations once you look. The frontier version of Chapter 7.