ROS via Rust
Chapter 01FoundationDifficulty: FoundationalEstimated reading time: 60 min

Foundation: Poses, Odometry and the First Filters

A robot that integrates its own wheels, why the step it takes must be exact, and the two ways to hold what it does not know about where it is.

Probabilistic robotics is a new and growing area in robotics, concerned with perception and control in the face of uncertainty.
MIT Presscatalog description of Probabilistic Robotics by Thrun, Burgard and Fox (2005)

In this chapter

A TurtleBot3 has two wheel encoders and no idea where it is. Every pose it will ever use is one it computed: the wheels turned by so much, so the robot must have moved by so much. That sentence hides three problems, and this chapter takes them in order.

The first is arithmetic. A pose in a plane is a position and a heading, and "moved by so much" is a motion applied in the robot's own frame. Add the two the obvious way and the answer is wrong in a way that does not go away when you shrink the time step. The second is noise: wheels slip and encoders count in whole ticks, so the same command does not always land in the same place, and what the robot knows after a move is a spread of places. The third is what to do with a spread. It can be held as a histogram that assumes nothing, or as two numbers that assume a great deal, and the chapter ends with one worked example of each.

The problem

Drive the robot on a circle: forward at v=1v = 1 m/s while turning at ω=0.5\omega = 0.5 rad/s. Its path is a circle of radius v/ω=2v/\omega = 2 m, and it takes 2π/ω≈12.572\pi/\omega \approx 12.57 s to come round. The robot's odometry has to track that path by adding up small motions, one per control step. The question is how far from the true position it ends up after 12 s, as a function of how long a step it takes.

Here is the experiment, run on the repository's own Rust. The orange poses are what the robot's odometry computes, the dark ones are what the textbook update x += v cos θ Δt computes, and the dashed circle is the truth.

w1.1The exact arcKills the idea that: integrating a velocity in short straight steps is good enough, so the step length does not matter
  • prediction
  • ground truth
The robot drives v = 1 m/s, ω = 0.5 rad/s, a circle of radius 2 m. Orange is the exponential-map step the robot's odometry takes; dark is the textbook Euler step, which is not code the robot runs. Notice that the orange poses sit on the dashed circle whatever the step, while the dark ones sit on a different, larger circle, shifted to the right. Drag the slider to 2 steps and the Euler end point is far off; drag it towards 24 and the error shrinks, roughly in proportion to Δt, while the orange error stays at rounding level.

The orange error is below 10−1210^{-12} m at every step length you can set. The dark one is not. At Δt=1.5\Delta t = 1.5 s (8 steps) the Euler end point is 0.2134 m from the truth, at Δt=3\Delta t = 3 s (4 steps) it is 0.4374 m, and halving the step from 1 s to 0.5 s halves the error, from 0.1416 m to 0.0706 m. Euler is not wrong by a random walk or by a drift that gets worse with time; as you saw, every one of its poses sits on one circle, just not the right one. The rest of this chapter exists to say why, and why the exact version is the one the robot runs.

Wheels bring a second problem. The same command given twice does not give the same pose, and that is not a flaw of one robot. It is what the next widget shows.

w1.2One command, many outcomesKills the idea that: noise only blurs the dead-reckoned pose symmetrically around the exact end point
  • prediction
The same command (v = 1 m/s, ω = 0.5 rad/s, 1 s) is given to 300 robots, repeated in a row; each draws its own noise from the repository's velocity motion model. The black line is the exact arc. Notice that the cloud is not a round blob centred on the black dot: raise the noise and add commands, and it spreads into a curved band that follows the arc, because an error in heading early on turns every later step.

After one command the possible poses are a cloud. After several they are a curved band. Whatever the robot does next, it does with a distribution over where it is, and a pose-with-a-radius is not a good summary of that distribution. The last two widgets of the chapter hold such a distribution in the two ways filters usually do.

Building intuition

Before any algebra, three things to take from the widgets.

A step is a rotation and a translation at once. Moving forward while turning does not move the robot along its old heading and then rotate it; the heading changes during the step, so the direction of travel changes during it too. The exact step is the arc of a circle, and that is what the orange poses follow. The Euler step goes straight along the old heading and only then turns, so each step is a straight segment in the direction the robot was facing, never the direction it was turning through, and its corners cannot land on the circle.

The error of the cheap step is systematic. Euler's poses are a regular polygon whose corners lie on a bigger circle that is shifted sideways. Nothing about it is random, so nothing averages out.

Noise is not isotropic. In the second widget, an error in heading early on rotates every later step, so the error in position is stretched along the arc. A filter that wants to say how sure it is has to know that, which is why the next chapters work with a covariance in the pose's own tangent space rather than with a radius.

The mathematics

A pose, and how poses combine

A planar pose is x=(x,y,θ)x = (x, y, \theta) with θ∈(−π,π]\theta \in (-\pi, \pi]. It is also a rigid motion: the one that takes the origin to (x,y)(x, y) and rotates by θ\theta. Written as a matrix, it is

T=(cos⁡θ−sin⁡θxsin⁡θcos⁡θy001).T = \begin{pmatrix} \cos\theta & -\sin\theta & x \\ \sin\theta & \cos\theta & y \\ 0 & 0 & 1 \end{pmatrix}.

Poses form a group, SE(2)\SEtwo. The product x1x2x_1 x_2 applies x2x_2 in the body frame of x1x_1, and the inverse x−1x^{-1} undoes xx. In the repository, that product is Se2::compose, the inverse is Se2::inverse, and Se2::between(a, b) is a−1ba^{-1}b: where bb is as seen from aa.

The exponential map: one exact step

A twist τ=(vx,vy,ω)\tau = (v_x, v_y, \omega) is a velocity in the body frame, translation first. If the robot holds it for one unit of time, it moves along the arc the next formula gives, where aa and bb are two functions of ω\omega:

exp⁡(τ)=(a vx−b vy,  b vx+a vy,  ω),a=sin⁡ωω,b=1−cos⁡ωω.\exp(\tau) = \Bigl(a\,v_x - b\,v_y,\; b\,v_x + a\,v_y,\; \omega\Bigr), \qquad a = \frac{\sin\omega}{\omega},\quad b = \frac{1-\cos\omega}{\omega}.
DerivationWhere a and b come from

Hold the twist for one unit of time. At time s∈[0,1]s \in [0,1] the heading is ωs\omega s, so the robot is moving in the world frame with velocity R(ωs) (vx,vy)TR(\omega s)\,(v_x, v_y)^{\T}, where RR is the rotation matrix. The position after one unit of time is the integral of that:

∫01(cos⁡ωs−sin⁡ωssin⁡ωscos⁡ωs)ds(vxvy)=(sin⁡ωω−1−cos⁡ωω1−cos⁡ωωsin⁡ωω)(vxvy)=(a−bba)(vxvy).\int_0^1 \begin{pmatrix} \cos\omega s & -\sin\omega s \\ \sin\omega s & \cos\omega s \end{pmatrix} ds \begin{pmatrix} v_x \\ v_y \end{pmatrix} = \begin{pmatrix} \dfrac{\sin\omega}{\omega} & -\dfrac{1-\cos\omega}{\omega} \\[2mm] \dfrac{1-\cos\omega}{\omega} & \dfrac{\sin\omega}{\omega} \end{pmatrix} \begin{pmatrix} v_x \\ v_y \end{pmatrix} = \begin{pmatrix} a & -b \\ b & a \end{pmatrix}\begin{pmatrix} v_x \\ v_y \end{pmatrix}.

As ω→0\omega \to 0 the matrix tends to the identity, a→1a \to 1 and b→0b \to 0, which is straight-line motion. The repository switches to the series a≈1−ω2/6a \approx 1 - \omega^2/6, b≈ω/2−ω3/24b \approx \omega/2 - \omega^3/24 below ∣ω∣=10−8|\omega| = 10^{-8}, because sin⁡ω/ω\sin\omega/\omega loses all its digits there.

A worked example: exp⁡((1,0,π/2))\exp\bigl((1, 0, \pi/2)\bigr) is a quarter circle of radius 2/π2/\pi, so the robot ends at (2/π, 2/π)(2/\pi,\, 2/\pi), heading π/2\pi/2. The formula gives a=2/πa = 2/\pi and b=2/πb = 2/\pi, so (a⋅1, b⋅1)=(2/π,2/π)(a\cdot 1,\, b\cdot 1) = (2/\pi, 2/\pi). The repository pins it as se2::tests::worked_example_quarter_arc, and the book's build checks it against the compiled module.

Two names the rest of the book uses. The step x⊞δ=x exp⁡(δ)x \bplus \delta = x\,\exp(\delta) applies a body-frame twist to a pose, and y⊟x=log⁡(x−1y)y \bminus x = \log(x^{-1}y) is its inverse: the twist that carries xx to yy. One step of a constant velocity for time Δt\Delta t is therefore

xt=xt−1⊞(v Δt,  0,  ω Δt),x_{t} = x_{t-1} \bplus (v\,\Delta t,\; 0,\; \omega\,\Delta t),

and it is exact for a robot that really does hold a constant (v,ω)(v, \omega) over the step. That last condition is the only approximation the step makes, and it does not depend on Δt\Delta t.

Why the Euler step fails, exactly

The Euler step moves along the old heading, then turns: p←p+v Δt (cos⁡θ,sin⁡θ)p \leftarrow p + v\,\Delta t\,(\cos\theta, \sin\theta), θ←θ+ωΔt\theta \leftarrow \theta + \omega\Delta t. It is not code the robot runs; the repository has it only as the strawman the first widget measures. Write φ=ω Δt\varphi = \omega\,\Delta t and positions as complex numbers. Each step adds vΔt eikφv\Delta t\,e^{ik\varphi}, so after NN steps

pN=v Δt 1−eiNφ1−eiφ.p_N = v\,\Delta t\,\frac{1 - e^{iN\varphi}}{1 - e^{i\varphi}} .
DerivationAll the Euler poses lie on one circle

Let c=vΔt/(1−eiφ)c = v\Delta t/(1 - e^{i\varphi}). Then pN−c=−c eiNφp_N - c = -c\,e^{iN\varphi}, so ∣pN−c∣=∣c∣|p_N - c| = |c| for every NN: all the poses lie on a circle of radius ∣c∣|c|. Since ∣1−eiφ∣=2sin⁡(φ/2)|1 - e^{i\varphi}| = 2\sin(\varphi/2),

∣c∣=v Δt2sin⁡(φ/2)=vω⋅φ/2sin⁡(φ/2).|c| = \frac{v\,\Delta t}{2\sin(\varphi/2)} = \frac{v}{\omega}\cdot\frac{\varphi/2}{\sin(\varphi/2)} .

The true radius is v/ωv/\omega, and (φ/2)/sin⁡(φ/2)>1(\varphi/2)/\sin(\varphi/2) > 1 for φ≠0\varphi \neq 0, so the Euler circle is larger. It is centred at (vΔt2, vΔt2cot⁡φ2)\bigl(\tfrac{v\Delta t}{2},\, \tfrac{v\Delta t}{2}\cot\tfrac{\varphi}{2}\bigr), not at (0,v/ω)(0, v/\omega). At Δt=1.5\Delta t = 1.5 s, φ=0.75\varphi = 0.75 and the radius is 2.048 m against the true 2 m. Both circles are traversed at the same angular rate, so the error is bounded and periodic, and it vanishes as Δt→0\Delta t \to 0 only at first order, which is the proportionality you saw: the book's checks assert every one of these numbers against the closed form, not against the Rust.

From wheels to a twist

The robot's wheels have radius rr and sit a distance ℓ\ell apart. If the left wheel rolls an arc ΔsL\Delta s_L and the right wheel an arc ΔsR\Delta s_R, the body moves forward by their mean and turns by their difference over the track:

Δs=ΔsR+ΔsL2,Δθ=ΔsR−ΔsLℓ,\Delta s = \frac{\Delta s_R + \Delta s_L}{2}, \qquad \Delta\theta = \frac{\Delta s_R - \Delta s_L}{\ell},

and the pose changes by exp⁡((Δs,0,Δθ))\exp\bigl((\Delta s, 0, \Delta\theta)\bigr). A worked example with the book's rover Rusty (ℓ=0.16\ell = 0.16 m): the left wheel rolls 0.72 m and the right 0.88 m. Then Δs=0.80\Delta s = 0.80 m, Δθ=1.0\Delta\theta = 1.0 rad, and the pose is (0.673176788,  0.367758155,  1.0)(0.673176788,\; 0.367758155,\; 1.0). As a check that does not use the code: the arc has radius Δs/Δθ=0.8\Delta s/\Delta\theta = 0.8 m, so x=0.8sin⁡1x = 0.8\sin 1 and y=0.8(1−cos⁡1)y = 0.8(1 - \cos 1), which is the same pair of numbers. Test diff_drive::tests::worked_example_wheel_arcs pins it.

Noise: the same command, many poses

Real wheels do not do what they are told. The velocity motion model of Thrun, Burgard and Fox (Table 5.3) models that by perturbing the commanded speeds before taking the exact step. It draws

v^=v+ε1,ω^=ω+ε2,γ^=ε3,\hat v = v + \varepsilon_{1},\quad \hat\omega = \omega + \varepsilon_{2},\quad \hat\gamma = \varepsilon_{3},

with zero-mean normal ε\varepsilon whose variances are α1v2+α2ω2\alpha_1 v^2 + \alpha_2\omega^2, α3v2+α4ω2\alpha_3 v^2 + \alpha_4\omega^2 and α5v2+α6ω2\alpha_5 v^2 + \alpha_6\omega^2, takes the exact arc at (v^,ω^)(\hat v, \hat\omega), and then turns by a final γ^ Δt\hat\gamma\,\Delta t. The repository's default is α=(0.02,0.02,0.02,0.02,0.005,0.005)\alpha = (0.02, 0.02, 0.02, 0.02, 0.005, 0.005). With every α=0\alpha = 0 the model returns the exact arc: for v=1v = 1, ω=0.5\omega = 0.5, Δt=1\Delta t = 1 the end pose is (2sin⁡0.5,  2(1−cos⁡0.5),  0.5)=(0.958851077208,  0.244834876219,  0.5)(2\sin 0.5,\; 2(1 - \cos 0.5),\; 0.5) = (0.958851077208,\; 0.244834876219,\; 0.5), which is exp⁡((1,0,0.5))\exp((1, 0, 0.5)) (velocity::tests::worked_example_exact_arc). The model is a sampler: to know what the robot might do, draw many poses, as the second widget does.

Belief, and the Bayes filter

The robot's knowledge of where it is, given everything it has been told and sensed, is a probability distribution bel⁡(xt)\bel(x_t) over poses. It changes in two ways. A motion spreads it out: given the control utu_t,

bel⁡‾(xt)=∫p(xt∣ut,xt−1) bel⁡(xt−1) dxt−1.\belbar(x_t) = \int p(x_t \mid u_t, x_{t-1})\,\bel(x_{t-1})\,dx_{t-1}.

A sensor reading ztz_t narrows it by Bayes' rule, where η\eta is whatever number makes the result sum to one:

bel⁡(xt)=η p(zt∣xt) bel⁡‾(xt).\bel(x_t) = \eta\, p(z_t \mid x_t)\,\belbar(x_t).

The number 1/η1/\eta is the evidence: how likely the reading was before it was seen. A very small evidence says the filter was surprised, and the repository's correct returns it so that the caller can notice. Both formulas need a representation of bel⁡\bel. The first one is a histogram.

A worked example. A 4 m corridor of four 1 m cells, robot position unknown, so the belief is (0.25,0.25,0.25,0.25)(0.25, 0.25, 0.25, 0.25). A sensor reports a likelihood of 0.2 in cells 0 and 2 and 0.8 in cells 1 and 3. Multiplying gives (0.05,0.2,0.05,0.2)(0.05, 0.2, 0.05, 0.2), which sums to 0.50.5, the evidence, and dividing by it gives bel⁡=(0.1,0.4,0.1,0.4)\bel = (0.1, 0.4, 0.1, 0.4). The test is histogram::tests::worked_example_four_cells. Notice that two cells have the highest belief, and that the belief still puts 20 per cent on the other two.

The Gaussian belief and the Kalman filter

A histogram over a corridor is 100 numbers. A histogram over (x,y,θ)(x, y, \theta) at the same resolution is 1003=106100^3 = 10^6. The alternative is to assume the belief is a Gaussian N(μ,Σ)\mathcal N(\mu, \Sigma) and carry only μ\mu and Σ\Sigma. If the motion and the measurement are linear and the noises Gaussian, that assumption is not an approximation: both steps of the Bayes filter map a Gaussian to a Gaussian, and the Kalman filter is their closed form. In one dimension, with Thrun's naming (RR is motion noise, QQ is measurement noise), the prediction is

μˉ=μ+u,Σˉ=Σ+R,\htmlClass{term-prediction}{\bar\mu = \mu + u}, \qquad \htmlClass{term-prediction}{\bar\Sigma = \Sigma + R},

and the correction multiplies the predicted Gaussian by the measurement's likelihood N(z;x,Q)\mathcal N(z; x, Q). Two Gaussians multiply to a Gaussian, and the result is

K=ΣˉΣˉ+Q,μ=μˉ+K (z−μˉ),Σ=(1−K) Σˉ.K = \frac{\bar\Sigma}{\bar\Sigma + Q}, \qquad \htmlClass{term-posterior}{\mu = \bar\mu + K\,(z - \bar\mu)}, \qquad \htmlClass{term-posterior}{\Sigma = (1 - K)\,\bar\Sigma}.
DerivationThe correction is a product of two Gaussians

Up to a constant, N(x;μˉ,Σˉ) N(z;x,Q)∝exp⁡ ⁣(−12[(x−μˉ)2/Σˉ+(z−x)2/Q])\mathcal N(x; \bar\mu, \bar\Sigma)\,\mathcal N(z; x, Q) \propto \exp\!\bigl(-\tfrac12[(x-\bar\mu)^2/\bar\Sigma + (z-x)^2/Q]\bigr). Collect the terms in xx: the exponent is −12[(1/Σˉ+1/Q) x2−2(μˉ/Σˉ+z/Q) x]+const-\tfrac12\bigl[(1/\bar\Sigma + 1/Q)\,x^2 - 2(\bar\mu/\bar\Sigma + z/Q)\,x\bigr] + \text{const}, which is a Gaussian with precision 1/Σ=1/Σˉ+1/Q1/\Sigma = 1/\bar\Sigma + 1/Q and mean μ=Σ (μˉ/Σˉ+z/Q)\mu = \Sigma\,(\bar\mu/\bar\Sigma + z/Q). Writing K=Σˉ/(Σˉ+Q)K = \bar\Sigma/(\bar\Sigma + Q),

Σ=ΣˉQΣˉ+Q=(1−K)Σˉ,μ=μˉ+K(z−μˉ).\Sigma = \frac{\bar\Sigma Q}{\bar\Sigma + Q} = (1 - K)\bar\Sigma, \qquad \mu = \bar\mu + K(z - \bar\mu).

The mean sits at the precision-weighted average of the two, so it is halfway between them only if their variances are equal. Check with the repository's pinned example: N(5,4)⋅N(6.5,1)\mathcal N(5, 4)\cdot\mathcal N(6.5, 1) has K=4/5K = 4/5, μ=5+0.8⋅1.5=6.2\mu = 5 + 0.8\cdot 1.5 = 6.2, Σ=0.2⋅4=0.8\Sigma = 0.2\cdot 4 = 0.8, which is N(6.2,0.8)\mathcal N(6.2, 0.8).

The gain KK is the whole story. When the measurement is precise (Q≪ΣˉQ \ll \bar\Sigma), K→1K \to 1 and the posterior follows the measurement. When it is noisy (Q≫ΣˉQ \gg \bar\Sigma), K→0K \to 0 and the posterior ignores it. Nothing in the filter splits the difference half and half unless Σˉ=Q\bar\Sigma = Q. The repository computes Σ\Sigma not as (1−K)Σˉ(1-K)\bar\Sigma but in the Joseph form,

Σ=(I−KC) Σˉ (I−KC)T+KQKT,\Sigma = (I - KC)\,\bar\Sigma\,(I - KC)^{\T} + K Q K^{\T},

which is algebraically the same and stays symmetric and positive-definite when rounding errors accumulate.

A worked example (kalman::tests::worked_example_1d_cart): a cart believed to be at N(0,1)\mathcal N(0, 1) is commanded to move u=1u = 1 with R=0.25R = 0.25 and then measured at z=1.7z = 1.7 with Q=0.5Q = 0.5. The prediction is N(1,1.25)\mathcal N(1, 1.25); the innovation variance is S=Σˉ+Q=1.75S = \bar\Sigma + Q = 1.75; the gain is K=1.25/1.75=5/7K = 1.25/1.75 = 5/7; the posterior is N(1.5, 2.5/7)\mathcal N(1.5,\, 2.5/7). The posterior mean is 71 per cent of the way from the prediction (1) to the measurement (1.7), not 50. Run the filter for a second step (u=1u = 1, z=2.1z = 2.1) and it gives μ=2.280645161290323\mu = 2.280645161290323, Σ=0.2741935483870968\Sigma = 0.2741935483870968.

The algorithm

Two tables in the style of Thrun's, with the symbols of this book, as the repository implements them.

AlgorithmBayesFilter(bel, u, z)
  1. predict: bel⁡‾(xt)=∫p(xt∣ut,xt−1) bel⁡(xt−1) dxt−1\belbar(x_t) = \int p(x_t \mid u_t, x_{t-1})\,\bel(x_{t-1})\,dx_{t-1}
  2. correct: bel⁡(xt)=η p(zt∣xt) bel⁡‾(xt)\bel(x_t) = \eta\,p(z_t \mid x_t)\,\belbar(x_t), with η−1=∫p(zt∣xt) bel⁡‾(xt) dxt\eta^{-1} = \int p(z_t \mid x_t)\,\belbar(x_t)\,dx_t
  3. return bel⁡(xt)\bel(x_t) and the evidence η−1\eta^{-1}
AlgorithmKalmanFilter(μ, Σ, u, z)
  1. μˉ=Aμ+Bu\bar\mu = A\mu + Bu, Σˉ=AΣAT+R\quad\bar\Sigma = A\Sigma A^{\T} + R
  2. S=CΣˉCT+QS = C\bar\Sigma C^{\T} + Q, K=ΣˉCTS−1\quad K = \bar\Sigma C^{\T} S^{-1}
  3. μ=μˉ+K(z−Cμˉ)\mu = \bar\mu + K(z - C\bar\mu)
  4. Σ=(I−KC) Σˉ (I−KC)T+KQKT\Sigma = (I - KC)\,\bar\Sigma\,(I - KC)^{\T} + KQK^{\T}
  5. return μ\mu, Σ\Sigma, and the evidence N(z; Cμˉ, S)\mathcal N(z;\, C\bar\mu,\, S)

Implementation in Rust

All of this is in three crates that do not know ROS exists: robotics_core, robotics_models and robotics_filters. Every listing below is an excerpt of the file named in its title; the book's build fails if a line of one is not in that file, in that order.

The pose and its exponential

crates/robotics_core/src/se2.rs
/// A planar pose: translation `(x, y)` in metres and heading `θ` in radians, `θ ∈ (−π, π]`.
///
/// The struct is `Copy`; composing poses does not allocate.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Se2 {
    x: f64,
    y: f64,
    theta: f64,
}
// …
    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 {
            let s = (0.5 * w).sin();
            (w.sin() / w, 2.0 * s * s / w)
        };
        Self::new(a * vx - b * vy, b * vx + a * vy, w)
    }
// …
    pub fn compose(&self, other: &Se2) -> Se2 {
        let (s, c) = self.theta.sin_cos();
        Se2::new(
            self.x + c * other.x - s * other.y,
            self.y + s * other.x + c * other.y,
            self.theta + other.theta,
        )
    }
// …
impl Manifold<3> for Se2 {
    #[inline]
    fn boxplus(&self, delta: &SVector<f64, 3>) -> Self {
        self.compose(&Se2::exp(delta))
    }
    #[inline]
    fn boxminus(&self, base: &Self) -> SVector<f64, 3> {
        base.between(self).log()
    }
}

Se2::new normalizes the heading, so the angle is always in (−π,π](-\pi, \pi]. exp is the formula above, with the series for small ω\omega; boxplus is x⊞δx \bplus \delta and boxminus is y⊟xy \bminus x. The trait Manifold is what every filter of later chapters is generic over.

Wheels to a pose

crates/robotics_models/src/diff_drive.rs
    pub fn wheel_arcs_to_tangent(&self, ds_left: f64, ds_right: f64) -> Tangent2 {
        Tangent2::new(
            0.5 * (ds_right + ds_left),
            0.0,
            (ds_right - ds_left) / self.wheel_separation,
        )
    }
// …
    fn apply(&mut self, delta: Tangent2, dt: f64) -> OdometryStep {
        let step_pose = Se2::exp(&delta);
        // Compound the covariance: the old uncertainty is carried through the new step
        // (Ad of the inverse increment) and the fresh step noise is added.
        let ad_inv = step_pose.inverse().adjoint();
        self.cov = ad_inv * self.cov * ad_inv.transpose() + self.noise.step_covariance(&delta);
        self.pose = self.pose.boxplus(&delta);

WheelOdometry::apply is the odometry step: it advances the pose by ⊞ Δ\bplus\,\Delta and, in the same breath, carries the old covariance through the new step with the adjoint Ad⁡\Ad of the inverse increment and adds the step's own noise. A pose uncertainty expressed in the body frame has to be rotated into the next body frame, and the adjoint is how. The ROS node wheel_odometry_node reads joint_states, calls this, and publishes /odom.

One limit belongs here, because the repository states it: the covariance this produces is a simple per-step noise model, and measured against simulated wheel slip it is over-confident, which docs/SOURCE_MAPPING.md (deviation 4) records and keeps as the raw-odometry baseline. A later chapter measures how over-confident; this one only asks you not to trust the number as a calibrated probability.

The noisy step

crates/robotics_models/src/motion/velocity.rs
    /// Table 5.3: `v̂ = v + N(0, α₁v² + α₂ω²)`, `ω̂ = ω + N(0, α₃v² + α₄ω²)`,
    /// `γ̂ = N(0, α₅v² + α₆ω²)`.
    fn sample<R: Rng + ?Sized>(&self, x_prev: &Se2, u: &VelocityCmd, rng: &mut R) -> Se2 {
        let a = &self.alphas.0;
        let (v2, w2) = (u.v * u.v, u.omega * u.omega);
        let v_hat = u.v + sample_normal(a[0] * v2 + a[1] * w2, rng);
        let w_hat = u.omega + sample_normal(a[2] * v2 + a[3] * w2, rng);
        let g_hat = sample_normal(a[4] * v2 + a[5] * w2, rng);
        Self::integrate(x_prev, v_hat, w_hat, g_hat, u.dt)
    }

The generator rng is passed in. The repository has no entry point that reads the operating system's entropy, so a run is a function of its seed, and every stochastic widget in this book shows its seed and lets you re-roll it.

The filters

crates/robotics_filters/src/bayes.rs
pub trait BayesFilter {
    /// The belief representation (a grid, a Gaussian, a particle set, …).
    type Belief;
    /// The control input type.
    type Control;
    /// The measurement type.
    type Measurement;
// …
    fn predict(&mut self, u: &Self::Control) -> Result<(), FilterError>;
// …
    fn correct(&mut self, z: &Self::Measurement) -> Result<f64, FilterError>;
// …
}

Both methods return a Result, which the 2005 book's trait does not (docs/SOURCE_MAPPING.md, deviation 2): a production node must be able to refuse a control or a measurement that is not finite. correct returns the evidence.

crates/robotics_filters/src/histogram.rs
    fn correct(&mut self, likelihood: &Self::Measurement) -> Result<f64, FilterError> {
        for i in 0..self.cells.len() {
            let l = likelihood(self.center(i));
            if !l.is_finite() || l < 0.0 {
                return Err(FilterError::NonFinite("likelihood"));
            }
            self.cells[i] *= l;
        }
        self.normalize()
    }

That is Bayes' rule on a grid: multiply each cell by the likelihood at its centre, then normalize, which returns the sum it divided by, the evidence. If every cell gets zero likelihood it returns ZeroLikelihood instead of a belief that is not a distribution.

crates/robotics_filters/src/kalman.rs
    fn predict(&mut self, u: &SVector<f64, U>) -> Result<(), FilterError> {
        if u.iter().any(|v| !v.is_finite()) {
            return Err(FilterError::NonFinite("control"));
        }
        let mean = self.a * self.belief.mean() + self.b * u;
        let cov = self.a * self.belief.cov() * self.a.transpose() + self.r;
        self.belief = Gaussian::new(mean, cov)?;
        Ok(())
    }

    fn correct(&mut self, z: &SVector<f64, M>) -> Result<f64, FilterError> {
// …
        let innovation = z - self.c * mu;
        let s = self.c * sigma * self.c.transpose() + self.q;
        let chol = Cholesky::new(s).ok_or(FilterError::SingularInnovation)?;
        let gain = sigma * self.c.transpose() * chol.inverse();
        let mean = mu + gain * innovation;
        let ikc = SMatrix::<f64, N, N>::identity() - gain * self.c;
        let cov = ikc * sigma * ikc.transpose() + gain * self.q * gain.transpose();
        self.belief = Gaussian::new(mean, cov)?;
// …
}

The generic KalmanFilter<N, U, M> is Table 3.1 in a few dozen lines. Notice that the covariance update is the Joseph form, and that Gaussian::new refuses a covariance that is not positive-definite, so a numerical failure becomes an error at the line that caused it.

The worked examples, run

The two examples of the chapter, reproduced by the book's build on the module the widgets use. Each number below is asserted by npm run check.

wheel arcs (Rusty, ℓ = 0.16):  Δs_L = 0.72, Δs_R = 0.88
    Δs = 0.80  Δθ = 1.0  pose = (0.673176788, 0.367758155, 1.0)

1-D cart, A = B = C = 1, R = 0.25, Q = 0.5, prior N(0, 1)
step 1  u = 1  predict  μ̄ = 1.0        Σ̄ = 1.25
        z = 1.7 correct S = 1.75       K = 5/7        μ = 1.5               Σ = 2.5/7
step 2  u = 1  predict  μ̄ = 2.5        Σ̄ = 4.25/7
        z = 2.1 correct S = 7.75/7     K = 4.25/7.75  μ = 2.280645161290323 Σ = 0.2741935483870968

Putting it together

The widget below is the cart of the example, with the gain as the thing to watch.

w1.4One Kalman stepKills the idea that: the filter splits the difference between what it predicted and what it measured, half and half
  • prior
  • prediction
  • measurement
  • posterior
A cart believed to be at 0 ± 1 is told to move 1 m (variance of the move 0.25) and then measured at 1.7 m. The posterior is purple. At the starting value Q = 0.5 it is not halfway between the orange prediction and the green reading: the gain is 5/7, so the posterior mean lands 71 per cent of the way to the reading. Drag Q to the right and it slides back to the prediction; drag it to the left and the reading takes over.

Now the hallway. A robot that does not know where it starts walks a 10 m corridor with three identical doors and asks a door detector, right 90 per cent of the time, after every 0.5 m. A HistogramFilter1D of 100 cells tracks it.

w1.3The hallway: a belief, not a pointKills the idea that: the filter's answer is its most probable cell, and one number is all it needs to carry
  • prediction
  • posterior
  • ground truth
A robot that does not know where it starts walks 0.5 m per step along a corridor with three identical doors (gray bands) and asks a door detector, which is right 90 per cent of the time, after every move. Orange outlines the belief after the move, purple bars after the reading; the dashed line is where the robot really is, the purple tick is the filter's most probable cell. Notice that each “door” reading raises a bump at every door, not just the real one, and that the most probable cell can sit metres from the truth while the belief still has mass on it. Re-roll to see other runs.

After the first "door" reading from the uniform belief, the belief has three equal bumps, each holding 22 per cent of the mass; the sensor cannot tell the doors apart, and that is a statement about the sensor, not a failure of the filter. Only later readings, with motion in between them, tell the doors apart. The filter's most probable cell is a poor answer to "where is the robot" whenever the belief has more than one bump, and a mean is no better: the mean of two bumps sits between them, where the robot is not. A belief is not a point.

Here is the problem of the chapter, restated as numbers.

  • The step. The exact step ends within 10−1210^{-12} m of the true pose at every step length, and the Euler step is 0.2134 m off at Δt=1.5\Delta t = 1.5 s.
  • The noise. The same command gives a cloud, and the cloud bends along the arc.
  • The belief. A histogram assumes nothing and costs a cell per resolution element; with three coordinates it costs a million. A Kalman filter costs two numbers per dimension and is exact only when the world is linear and Gaussian, which a pose is not: it lives on a curved space, and the next chapters' filters work on its tangent space for exactly that reason.

What this chapter does not show, and the repository says it does not: the odometry covariance of Chapter 1 is not calibrated, the histogram filter is one-dimensional, and the Kalman filter in the widget is the scalar case. The first is measured in a later chapter, and the other two are the reason a later chapter exists.

Exercises

  1. Foundation: derive exerciseDifficulty 2 of 3One circle, which radius

    Show that the Euler poses of the first widget lie on a single circle and find its radius in terms of vv, ω\omega and Δt\Delta t. Which direction is its centre shifted from the true centre (0,v/ω)(0, v/\omega)?

    Solution

    With φ=ωΔt\varphi = \omega\Delta t, pN=vΔt (1−eiNφ)/(1−eiφ)p_N = v\Delta t\,(1 - e^{iN\varphi})/(1 - e^{i\varphi}) is a geometric sum. Put c=vΔt/(1−eiφ)c = v\Delta t/(1 - e^{i\varphi}): then pN−c=−c eiNφp_N - c = -c\,e^{iN\varphi} and ∣pN−c∣=∣c∣|p_N - c| = |c|, a constant. Since ∣1−eiφ∣=2sin⁡(φ/2)|1 - e^{i\varphi}| = 2\sin(\varphi/2) the radius is vΔt/(2sin⁡(φ/2))v\Delta t/(2\sin(\varphi/2)). The centre is c=(vΔt2,vΔt2cot⁡φ2)c = \bigl(\tfrac{v\Delta t}{2}, \tfrac{v\Delta t}{2}\cot\tfrac{\varphi}{2}\bigr), which has a positive xx component: shifted to the right in the widget.

  2. Conceptual: predict, then check exerciseDifficulty 1 of 3A very noisy sensor
    Predict first

    In the last widget, drag Q to its largest value. Where does the posterior end up?

  3. Conceptual: predict, then check exerciseDifficulty 2 of 3Which door?
    Predict first

    Start the hallway widget with a uniform belief and a detector that says door. How many bumps does the belief have, and how tall are they?

  4. Foundation: derive exerciseDifficulty 2 of 3The gain from scratch

    Derive K=Σˉ/(Σˉ+Q)K = \bar\Sigma/(\bar\Sigma + Q) and Σ=(1−K)Σˉ\Sigma = (1 - K)\bar\Sigma by multiplying the two Gaussians of the scalar correction, and evaluate them for Σˉ=1.25\bar\Sigma = 1.25, Q=0.5Q = 0.5.

    Solution

    Add the precisions: 1/Σ=1/Σˉ+1/Q1/\Sigma = 1/\bar\Sigma + 1/Q, so Σ=ΣˉQ/(Σˉ+Q)\Sigma = \bar\Sigma Q/(\bar\Sigma + Q). The mean is Σ(μˉ/Σˉ+z/Q)=μˉ+K(z−μˉ)\Sigma(\bar\mu/\bar\Sigma + z/Q) = \bar\mu + K(z - \bar\mu) with K=Σ/Q=Σˉ/(Σˉ+Q)K = \Sigma/Q = \bar\Sigma/(\bar\Sigma + Q). For the numbers given, K=1.25/1.75=5/7K = 1.25/1.75 = 5/7 and Σ=(2/7)(1.25)=2.5/7\Sigma = (2/7)(1.25) = 2.5/7.

  5. Practical: write Rust exerciseDifficulty 1 of 3Wheels to a pose, in your own test

    In crates/robotics_models/src/diff_drive.rs, write a test that turns the wheel arcs (0.52,0.68)(0.52, 0.68) into a pose with wheel_arcs_to_tangent and Se2::exp, and check the result against the closed form x=(Δs/Δθ)sin⁡Δθx = (\Delta s/\Delta\theta)\sin\Delta\theta, y=(Δs/Δθ)(1−cos⁡Δθ)y = (\Delta s/\Delta\theta)(1 - \cos\Delta\theta) rather than against another call of the same code.

    Solution sketch

    Δs=0.60\Delta s = 0.60, Δθ=0.16/0.16=1.0\Delta\theta = 0.16/0.16 = 1.0, so the expected pose is (0.6sin⁡1, 0.6(1−cos⁡1), 1)(0.6\sin 1,\ 0.6(1 - \cos 1),\ 1). Compare with assert_relative_eq! at 10−1210^{-12}. worked_example_wheel_arcs in the same file is the template.

  6. Practical: write Rust exerciseDifficulty 2 of 3A corridor that wraps

    Add a test to crates/robotics_filters/src/histogram.rs that a wrapped HistogramFilter1D returns a one-cell belief to its starting cell after predict(&1.0) has been called once per cell of the corridor.

    Solution sketch

    Use HistogramFilter1D::uniform(4, 4.0, true), correct with a likelihood that is 1 in one cell and 0 elsewhere, then call predict(&1.0) four times and assert the belief is back on the same cell. exact_shift_moves_mass shows the pattern for a single step.

References

  1. Thrun, S., Burgard, W. and Fox, D. (2005) Probabilistic Robotics. MIT Press, Intelligent Robotics and Autonomous Agents series.link to Probabilistic Robotics (opens in a new tab)

    The motion model, Bayes filter and Kalman filter tables this chapter follows; the epigraph is the publisher's description of this book.

  2. 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)

    Why the exponential map, the right perturbation and the adjoint in this chapter are written the way they are.

  3. Kalman, R. E. (1960) A new approach to linear filtering and prediction problems. Journal of Basic Engineering 82(1), 35–45.doi:10.1115/1.3662552 (opens in a new tab)

    The original filter; the scalar case of this chapter is its simplest instance.

  4. Barfoot, T. D. (2017) State Estimation for Robotics. Cambridge University Press.doi:10.1017/9781316671528 (opens in a new tab)

    A textbook treatment of poses as Lie group elements and of the Kalman filter on them, the path the next chapters take.

  5. Borenstein, J. and Feng, L. (1996) Measurement and correction of systematic odometry errors in mobile robots. IEEE Transactions on Robotics and Automation 12(6), 869–880.doi:10.1109/70.544770 (opens in a new tab)

    Why wheel odometry is wrong in ways that do not average out, which is the reason to keep a distribution rather than a point.

  6. Macenski, S., Foote, T., Gerkey, B., Lalancette, C. and Woodall, W. (2022) Robot Operating System 2: Design, architecture, and uses in the wild. Science Robotics 7(66).doi:10.1126/scirobotics.abm6074 (opens in a new tab)

    The middleware the repository's nodes run on; this chapter's crates deliberately do not depend on it.