ROS via Rust
Chapter 03State estimation, sensor models, simulationDifficulty: IntermediateEstimated reading time: 70 min

Sensor Models and the Simulator

How a simulated lidar produces a scan you can replay from a seed, what a range reading means as a probability, how the beam model and the likelihood field score a pose, and why a scan's likelihood has to be tempered.

To navigate reliably in indoor environments, a mobile robot must know where it is.
F. Dellaert, D. Fox, W. Burgard and S. ThrunMonte Carlo Localization for Mobile Robots (1999), abstract

In this chapter

Chapter 2 ended with a pose that drifts, and a promise that a second kind of evidence would correct it. The evidence is a lidar: 360 distances, ten times a second. A distance means nothing to a probabilistic filter until a model says how probable it is, and the whole of localization, from Chapter 4 on, will lean on one function: the probability of a scan if the robot were at a given pose in a given map.

That function has two ends and this chapter builds both. At one end is a simulator that produces scans a reader can replay from a seed, so that every later number can be reproduced. At the other are the sensor models that score a scan, and three questions about them: what does a single reading mean, what is a cheap way to score a hundred poses a scan, and how sure is a score entitled to be?

The problem

A lidar sweeps a beam a degree at a time and reports how far it went. Rusty, the repository's simulator, does the same thing on a map made of line segments: a 13 m × 13 m arena, 21 segments in all, with box obstacles and a wall stub. Each beam is cast exactly against the segments, Gaussian noise of 2 cm is added, one beam in a hundred returns nothing, and anything beyond 8 m is nothing. Drag the robot around.

w3.1One sweep of the simulated lidarKills the idea that: a beam that returns nothing means there is nothing out there
  • measurement
  • prediction
  • ground truth
Rusty's lidar fires 360 beams a degree apart from a point 5 cm ahead of the robot's centre (the black dot) into the 21-segment arena. Each beam is cast exactly against the walls, then gets Gaussian range noise, and with the dropout probability returns nothing at all. Green dots are the readings; orange rings are walls the beam reached but whose reading was lost. Drag in the arena to move the robot; slide the heading. Two different things read as “no return”: a wall farther than the 8 m the lidar can see, and a dropout (the simulator's default is 1 %). The robot cannot tell them apart from the reading alone, only a map can. The numbers are this page's own draw of the seed shown beside Re-roll.

Green dots are what the robot would read. Two kinds of beam read nothing, and they look the same in the data: a wall that is farther than 8 m away, and a dropout. From the start pose of the widget 57 of the 360 beams reach no wall within range, and a handful more are lost; the reading alone cannot tell which is which, a map can. (From the middle of the arena, with noise and dropout off, 53 beams still reach nothing, because the corners are more than 8 m away.) That is the first lesson of a sensor model: a missing return is a piece of evidence, and an ambiguous one.

The second lesson is about what the question is. A localizer will hold many guesses of the pose and ask, of each, how probable it is that the lidar would have read this. The guess that makes the scan likely wins. So a sensor model is a function p(z | x, m) of the scan z, the pose x and the map m, and the rest of the chapter is two ways of writing it, and the problem each has.

Same Rust, different noise, again

The note at the start of Chapter 2 applies here without change. The simulator's random streams come from robotics_core::seeded_rng, and a seed names different noise on this page (a 32-bit WebAssembly module) than on the robot. The scan of the widget above is this page's own draw; the same scan, ch03_lidar_scan(1, 2, 0.4, 0.02, 0.01, seed 7), has the fingerprint 8a526fd3ad0062b1 where the repository runs natively and f7e036522696593c here, and the build pins both. What is deterministic and has no random stream (the ray cast, the beam density, the worked EM example, the distance transform, the landmark model) is the same on both and is checked against closed forms and the repository's own numbers.

Building intuition: a simulator you can replay

The forward model

Write the sensor's pose as Tsensor=x∘Tbase→laserT_{\text{sensor}} = x \circ T_{\text{base→laser}}, the robot's pose composed with the mount, 5 cm ahead of the base. Beam kk points at the bearing φk=−π+k⋅1∘\varphi_k = -\pi + k\cdot 1^\circ. Its ideal range zk∗z^*_k is where the ray first meets a wall. For the ray o+tdo + t d and a segment a+sea + s e, solve the two-by-two system with cross products, t=(a−o)×e / (d×e)t = (a - o)\times e \,/\, (d \times e) and s=(a−o)×d / (d×e)s = (a - o)\times d \,/\, (d \times e), and accept the hit when t≥0t \ge 0 and ss lies on the segment (with a tolerance so that a ray through a shared corner cannot leak). The first hit is the smallest tt, if it is within 8 m. The measurement is then

zk=zk∗+σ nk,nk∼N(0,1),z_k = z^*_k + \sigma\, n_k,\qquad n_k \sim \mathcal N(0, 1),

unless the beam is dropped, which happens with a fixed probability and also when there is no wall at all.

A worked example, with no random stream in it, so that it is the same on the page and on the robot: from the origin, facing +x, with no noise, beam 180 (straight ahead) reads 7.45 m, because the east wall is at 7.5 m and the sensor is 5 cm ahead of the base; beam 0 (straight behind) reads 5.55 m; beam 90 reads 4.0 m; beam 270 reads nothing, because the wall it points at is 9 m away. The repository pins them by hand in tests/lidar_regression.rs, and this book's build checks them against the compiled module.

crates/robotics_sim/src/world.rs
    /// First intersection of the ray `origin + t·(cos angle, sin angle)`, `t ≥ 0`, with
    /// any segment, if it lies within `max_range`. Rays parallel to a segment do not hit
    /// it (a grazing ray along a wall is a measure-zero event for a real sensor too).
    pub fn raycast(&self, origin: &Vector2<f64>, angle: f64, max_range: f64) -> Option<f64> {
        let d = Vector2::new(angle.cos(), angle.sin());
        let mut best = f64::INFINITY;
        for seg in &self.segments {
            let e = seg.b - seg.a;
            let denom = cross(&d, &e);
            if denom.abs() < 1e-15 {
                continue;
            }
            let ao = seg.a - origin;
            let t = cross(&ao, &e) / denom;
            let s = cross(&ao, &d) / denom;
            if t >= 0.0 && (-PARAM_EPS..=1.0 + PARAM_EPS).contains(&s) && t < best {
                best = t;
            }
        }
        (best <= max_range).then_some(best)
    }
crates/robotics_sim/src/lidar.rs
    pub fn scan(&mut self, world: &World, body: &Se2, stamp: Timestamp) -> LaserScan {
        let ideal = self.ideal_ranges(world, body);
        let c = &self.cfg;
        let mut ranges = Vec::with_capacity(c.beams);
// …
        for z_star in ideal {
            let u: f64 = self.rng.random::<f64>();
            let n: f64 = self.rng.sample(StandardNormal);
            let dropped = u < c.dropout_probability;
            let z = match (dropped, z_star) {
                (false, Some(z)) => Some(z + c.range_sigma * n),
                _ => None,
            };
            let r = match (c.no_return, z) {
                (NoReturnPolicy::Infinity, None) => f64::INFINITY,
                (NoReturnPolicy::Infinity, Some(z)) if z > c.range_max => f64::INFINITY,
                (NoReturnPolicy::Infinity, Some(z)) if z < c.range_min => f64::NEG_INFINITY,
                (NoReturnPolicy::Infinity, Some(z)) => z,
                (NoReturnPolicy::MaxRange, None) => c.range_max,
                (NoReturnPolicy::MaxRange, Some(z)) => z.clamp(0.0, c.range_max),
            };
            ranges.push(r as f32);
        }
// …
    }

Two things in the second listing carry the next idea. Every beam draws a uniform number and a normal one whether or not it uses them. And the encoding of "nothing" is a choice: in the default, REP 117 (+inf for no return or beyond range, −inf for closer than 0.12 m); in the book's convention, the sensor's limit, 8 m. The toggle in the widget switches between them, and the beam model below scores both identically, because it treats a non-finite reading as a max-range one.

Replay

The simulator promises a thing a reader can test. It has one random generator for each noise source, wheel slip, gyro white noise, gyro bias drift and the lidar, each derived from the master seed. Because every beam draws its two numbers regardless, changing the dropout probability does not reshuffle the noise of the beams that survive. Because the generators are separate, taking the lidar away cannot change a bit of the others.

w3.2Same seed, same data: replayKills the idea that: adding a sensor to a simulation changes the noise of the sensors already there
Four runs of the same 30 s drive. B repeats A and is identical in every stream: a seed is a recording. C removes the lidar and the encoders, the gyro and the true pose do not change by a bit, because every noise source has a random stream of its own, derived from the master seed. D takes the next seed: the gyro, the true pose and the scans change. The encoders do not, and that is physics, not a bug: wheel slip moves the robot over the floor, but an encoder counts how far the wheel turned, which slip does not change. That is why wheel odometry drifts without knowing it (Chapter 2). The fingerprints are this page's own; on the robot the same seed names a different stream (see the note on seeds in Chapter 2).

Read the table by columns. B repeats A and agrees everywhere. C, with no lidar, agrees with A in the encoders, the gyro and the true pose, and has no scans. D takes the next seed, and everything that has noise in it changes, except the encoders. That is not an accident: the simulator advances the wheel angle by the commanded rate, and slip and the radius error act on how far the robot travels, not on how far the wheel turned. An encoder cannot see them. This is the whole of the wheel-odometry problem of Chapter 2, in one column of identical fingerprints.

The mathematics

The beam model

Given the distance z∗z^* the beam should have travelled, a range reading is one of four things, and the beam model gives each its density:

p(z∣z∗)=zhit phit+zshort pshort+zmax pmax+zrand prand.p(z \mid z^*) = z_{\text{hit}}\, p_{\text{hit}} + z_{\text{short}}\, p_{\text{short}} + z_{\text{max}}\, p_{\text{max}} + z_{\text{rand}}\, p_{\text{rand}}.

The hit is the wall with noise, a Gaussian around z∗z^* cut to [0,zmax⁡][0, z_{\max}]: phit=N(z;z∗,σhit2)/ηp_{\text{hit}} = \mathcal N(z; z^*, \sigma_{\text{hit}}^2)/\eta, with η\eta the Gaussian's mass on that interval. The short reading is something unexpected between the sensor and the wall, a person or a chair, whose distance falls off exponentially and which is cut off at z∗z^*: pshort=λe−λz/(1−e−λz∗)p_{\text{short}} = \lambda e^{-\lambda z}/(1 - e^{-\lambda z^*}) on [0,z∗][0, z^*]. The max reading is a point mass at the sensor's limit, and the random reading is a flat floor, 1/zmax⁡1/z_{\max}. The weights are the book's defaults, 0.74, 0.10, 0.06 and 0.10, and σhit=0.12\sigma_{\text{hit}}=0.12 m, λ=1\lambda = 1 per metre.

w3.3What one beam can readKills the idea that: a range sensor reads the expected distance plus a little Gaussian noise, and nothing else
The beam model says that, given the distance z* the beam should travel (found by ray casting the map), the reading is one of four things: the wall with Gaussian noise (the narrow green peak), something unexpected in front of it (the orange ramp, nearer than z*), the sensor's limit (the point mass at 8 m) or garbage (the flat blue floor). The weights are the book's defaults, 0.74, 0.10, 0.06 and 0.10. Drag z*: the peak follows it and the ramp ends at it. Widen σ_hit and the peak flattens but the total mass stays 1. This is a model of one beam; the robot's scan of 360 beams is scored by multiplying 360 of these, which the next widgets will question.

A score for a whole scan multiplies the beams, ∑kln⁡p(zk∣zk∗)\sum_k \ln p(z_k \mid z^*_k), with zk∗z^*_k found by ray casting the map from the hypothesised pose. The density integrates to one, as a density must, and the build checks it at three expected ranges.

crates/robotics_models/src/sensor/beam.rs
    /// Component densities for measured `z` and expected `z_star`.
    pub fn components(&self, z: f64, z_star: f64) -> BeamComponents {
        let p = &self.p;
        let zmax = p.max_range;
        let hit = if (0.0..=zmax).contains(&z) {
            let mass =
                normal_cdf((zmax - z_star) / p.sigma_hit) - normal_cdf(-z_star / p.sigma_hit);
            if mass > 1e-12 {
                normal_pdf(z - z_star, p.sigma_hit) / mass
            } else {
                0.0
            }
        } else {
            0.0
        };
        let short = if z_star > 0.0 && (0.0..=z_star).contains(&z) {
            p.lambda_short * (-p.lambda_short * z).exp() / (1.0 - (-p.lambda_short * z_star).exp())
        } else {
            0.0
        };
        let max = if z >= zmax - 1e-9 { 1.0 } else { 0.0 };
// …
    /// Mixture likelihood `p(z | z*)`.
    pub fn likelihood(&self, z: f64, z_star: f64) -> f64 {
        let c = self.components(z, z_star);
        let p = &self.p;
        p.z_hit * c.hit + p.z_short * c.short + p.z_max * c.max + p.z_rand * c.rand
    }

Learning the parameters

Six numbers sit in that model, and the book's rule is that nobody should guess them. Given readings ziz_i with their expected ranges zi∗z^*_i, expectation–maximization finds them. The E-step shares each reading among the four components in proportion to how well each explains it, ei,c=zc pc(zi)/∑c′zc′ pc′(zi)e_{i,c} = z_c\,p_c(z_i)\big/\sum_{c'} z_{c'}\,p_{c'}(z_i). The M-step re-estimates each parameter from its share:

zc=1n∑iei,c,σhit2=∑iei,hit (zi−zi∗)2∑iei,hit,λshort=∑iei,short∑iei,short zi.z_c = \frac1n\sum_i e_{i,c},\qquad \sigma_{\text{hit}}^2 = \frac{\sum_i e_{i,\text{hit}}\,(z_i - z^*_i)^2}{\sum_i e_{i,\text{hit}}},\qquad \lambda_{\text{short}} = \frac{\sum_i e_{i,\text{short}}}{\sum_i e_{i,\text{short}}\, z_i}.

A worked example the repository pins in beam::tests::worked_example_ch10_em_iteration, with five readings of a wall at 5 m:

readings 4.5, 5, 3, 7, 10 at z* = 5, z_max = 10, uniform weights ¼, σ_hit = 1, λ = ½
after one EM step:
  z_hit   = 0.394282      z_short = 0.127210
  z_max   = 0.2           z_rand  = 0.278508
  σ_hit   = 1.093905      λ_short = 0.283737   (0.2837390 in full precision)
  mean log-likelihood per reading, before the step: −2.275038
w3.4Learning the sensor's parametersKills the idea that: a sensor model's parameters have to be tuned by hand against logged scans
  • posterior
  • ground truth
The model has six numbers, and nobody should guess them. Given only readings and the distance each beam should have read, expectation–maximization estimates all six: each reading is shared out among the four components in proportion to how well each explains it, and each parameter is re-estimated from its share. Here 20 000 readings are drawn from a mixture with known parameters (the dashed ticks), and the fit starts from four equal weights, σ_hit = 0.5 m and λ = 0.5 /m, nowhere near them. Play it. The weights land within 0.02 of the truth and σ_hit within 2 mm, the log-likelihood climbs within a few iterations, and two things are not clean: λ_short settles above its true value, because the book's update ignores that the short component is cut off at z*, and the climb dips by about 10⁻⁴ nats per beam near the end, because that same shortcut makes the step not an exact maximization. The readings are simulated from the model itself, so this shows the fitting code works, not that a real lidar obeys the model. The numbers are this page's own draw of the seed shown beside Re-roll.

The weights land within 0.02 of the truth, σhit\sigma_{\text{hit}} within 2 mm, and the log-likelihood climbs within a few iterations; two things are not clean, because the repository implements the book's update as printed. λshort\lambda_{\text{short}} settles above its true value, because the update ignores that pshortp_{\text{short}} is cut off at z∗z^*; and for the same reason the M-step is not an exact maximization, so the trace can dip by about 10−410^{-4} nats per beam near the end (docs/SOURCE_MAPPING.md, deviation 8). The page's readings are drawn from the model itself, by code that is marked in robotics_wasm as not being crate code (the crate has the density and the fit, not a sampler), so the demonstration is that the fitting code finds a known answer, not that a real lidar obeys the model.

crates/robotics_models/src/sensor/beam.rs
    /// One EM iteration of `learn_intrinsic_parameters` (Thrun Table 6.2) over readings
    /// `z` paired with their ray-cast expectations `z_star` (cached outside the loop).
    ///
    /// E-step: responsibilities `e_{i,c} = z_c p_c(z_i) / Σ_c' z_c' p_c'(z_i)`.
    /// M-step: `z_c = mean_i e_{i,c}`,
    /// `σ_hit² = Σ e_{i,hit}(z_i − z_i*)² / Σ e_{i,hit}`,
    /// `λ_short = Σ e_{i,short} / Σ e_{i,short} z_i`.
    ///
    /// Readings no component can explain are skipped (and counted); a component with no
    /// responsibility keeps its shape parameter; `σ_hit ≥ 1 mm` and
    /// `λ_short ∈ [1e-3, 50]` keep the next model valid. Non-finite readings are treated
    /// as max-range returns, as everywhere else in this model. `λ_short` inherits the
    /// truncation bias the book documents (Chapter 10, EM derivation, step 6).
    pub fn em_step(&self, z: &[f64], z_star: &[f64]) -> Result<EmStep, ModelError> {
// …
        for (&zi, &si) in z.iter().zip(z_star) {
            let zi = if zi.is_finite() { zi } else { p.max_range };
            let c = self.components(zi, si);
            let w = [
                p.z_hit * c.hit,
                p.z_short * c.short,
                p.z_max * c.max,
                p.z_rand * c.rand,
            ];
            let total: f64 = w.iter().sum();
            if total <= 0.0 || !total.is_finite() {
                continue;
            }
            used += 1;
            s_hit += w[0] / total;
            s_short += w[1] / total;
            s_max += w[2] / total;
            s_rand += w[3] / total;
            sq_hit += w[0] / total * (zi - si).powi(2);
            z_short_sum += w[1] / total * zi;
            ll += total.ln();
// …
        let n = used as f64;
        let params = BeamModelParams {
            z_hit: s_hit / n,
            z_short: s_short / n,
            z_max: s_max / n,
            z_rand: s_rand / n,
            sigma_hit: if s_hit > 1e-9 {
                (sq_hit / s_hit).sqrt().max(1e-3)
            } else {
                p.sigma_hit
            },
            lambda_short: if z_short_sum > 1e-9 {
                (s_short / z_short_sum).clamp(1e-3, 50.0)
            } else {
                p.lambda_short
            },
            max_range: p.max_range,
        };

The likelihood field

The beam model needs a ray cast against the map for every beam of every hypothesis. A cheaper score drops each measured end point into the map and asks one question of it: how far is the nearest wall? Precompute the answer once for every cell of a grid, with the exact Euclidean distance transform (Felzenszwalb and Huttenlocher, in linear time), and the score of a beam is a lookup,

q=∑kln⁡(zhit N(dk; 0, σhit2)+zrand/zmax⁡),q = \sum_{k}\ln\bigl(z_{\text{hit}}\,\mathcal N(d_k;\,0,\,\sigma_{\text{hit}}^2) + z_{\text{rand}}/z_{\max}\bigr),

with dkd_k the distance from the kk-th end point to the nearest wall, and defaults zhit=0.9z_{\text{hit}}=0.9, zrand=0.1z_{\text{rand}}=0.1, σhit=0.2\sigma_{\text{hit}}=0.2 m. Max-range beams are skipped, since they have no end point. This is not a model of how scans are generated; it is a smooth, cheap score that a particle filter can use, and the repository says so in its docs.

crates/robotics_models/src/sensor/likelihood_field.rs
    /// Log-likelihood of `scan` from robot pose `pose`, with the sensor mounted at
    /// `sensor_offset` in the body frame, using every `stride`-th valid beam.
    pub fn log_likelihood<F: DistanceField>(
        &self,
        scan: &LaserScan,
        pose: &Se2,
        sensor_offset: &Se2,
        field: &F,
        stride: usize,
    ) -> f64 {
        let sensor = pose.compose(sensor_offset);
        let p = &self.p;
        let max = p.max_range.min(scan.range_max);
        scan.valid_beams_strided(stride)
            .filter(|(_, r)| *r < max - 1e-9)
            .map(|(b, r)| {
                let end = sensor.act(&Vector2::new(r * b.cos(), r * b.sin()));
                let d = field.distance(&end);
                (p.z_hit * normal_pdf(d, p.sigma_hit) + p.z_rand / p.max_range)
                    .max(1e-300)
                    .ln()
            })
            .sum()
    }

The picture below is the field of the arena, brightest next to a wall, with a scan on it. The distance map is rasterized by the page's own wiring (the repository has no rasterizer yet; it is marked in robotics_wasm as not being crate code), which draws each wall as a one-cell-thick outline on a 5 cm grid. Remember that: it will matter.

w3.5Scoring a pose: the field and the beam modelKills the idea that: the model that is the exact physics of the sensor is also the best score for finding the pose
  • posterior
  • measurement
  • ground truth
One scan, taken at the true pose, is scored at hypothesised poses slid away from it. The beam model ray casts the map from each hypothesis and compares 360 expected ranges with the measured ones; the likelihood field instead drops each measured end point into the map and asks how far the nearest wall is, which a precomputed distance map (the picture, brighter near walls) answers in one lookup. Both peak at the truth. The beam model's peak is narrower and, on this scan, sits on it; the field's is about twice as wide and a centimetre or two off, since its map is a grid of 5 cm cells and its score depends only on the distance to a wall. In return it costs a lookup per beam, not a ray cast against every wall, and its surface is smooth, which is why it is the default laser model of Nav2's AMCL. Change the axis, and re-roll for other poses: the beam model's peak stays within a centimetre of the truth and the field's within three, and the field's is about twice as wide (never under 1.7 times on the poses and seeds the build tries). The numbers are this page's own draw.

Both models peak at the truth. The beam model's peak is sharper (on every pose and seed the build tries, within 1 cm of the truth along x and y, and within 0.01 rad in heading), the likelihood field's is within 3 cm and about twice as wide (never under 1.7 times on the poses and seeds the build tries). In exchange the field is smooth, so a search or a particle filter can use it; and it costs a table lookup per beam instead of a ray cast against 21 segments, 7 560 segment tests per 360-beam scan.

Too sure of itself

A scan has about 300 usable beams, and both models multiply them as if each were independent evidence. They are not independent: a 5 cm map cell biases every beam that meets it the same way, and a person walking through the scan blocks twenty adjacent beams. The product then overstates the information by roughly the ratio of the number of beams to the number of independent ones, and the posterior is narrower than the scan can justify.

The remedy is to raise the likelihood to a power, p(z∣x)βp(z\mid x)^{\beta} with 0<β≤10<\beta\le 1. In log space it is a multiplication, so the surface flattens without its peak moving, and no data are thrown away (subsampling beams would move the peak; inflating σhit\sigma_{\text{hit}} would change which beams count as outliers). The repository adds the rule that sets β\beta from a target: the largest β\beta whose weights keep the effective sample size above a given fraction.

crates/robotics_models/src/sensor/tempering.rs
    /// `β · ln p`. `−∞` (zero likelihood) stays `−∞`; any finite value stays finite.
    #[inline]
    pub fn apply_log(&self, log_likelihood: f64) -> f64 {
        self.beta * log_likelihood
// …
        let ess_ratio = |beta: f64| -> f64 {
            let lw: Vec<f64> = log_prior
                .iter()
                .zip(log_likelihoods)
                .map(|(p, l)| p + beta * l)
                .collect();
            let max = lw.iter().copied().fold(f64::NEG_INFINITY, f64::max);
            if max == f64::NEG_INFINITY {
                return 0.0;
            }
            let (s, s2) = lw.iter().fold((0.0, 0.0), |(s, s2), l| {
                let w = (l - max).exp();
                (s + w, s2 + w * w)
            });
            s * s / s2 / n
// …
        if ess_ratio(1.0) >= target_ratio {
            return Ok(Self::NONE);
        }
        if ess_ratio(min.beta) < target_ratio {
            return Ok(min);
        }
        let (mut lo, mut hi) = (min.beta, 1.0);
        for _ in 0..50 {
            let mid = 0.5 * (lo + hi);
            if ess_ratio(mid) >= target_ratio {
                lo = mid;
            } else {
                hi = mid;
            }
        }
        Self::new(lo)
    }

How over-confident, and does tempering cure it? The widget measures it. It simulates 64 scans, 8 poses with 8 noise seeds each, scores each along x, and asks of the posterior over that line: how large is the error of its mean against the true pose, how large is the standard deviation it claims, and what is the root-mean-square of their ratio. A calibrated posterior has a ratio of 1.

w3.6Too sure of itself: temperingKills the idea that: the sharper a posterior is, the better it is
  • posterior
  • ground truth
A scan is 300 or so beam readings multiplied as if they were independent evidence. They are not: a 5 cm map cell biases every beam the same way. The result is a posterior (one scan; the dashed line is the truth) that is narrower than the scan can justify. The statistics are over 64 scans from eight poses: the error of the posterior's mean against its own σ. With the likelihood field and β = 1 the error is bigger than the σ the model claims (the ratio is above 1: over-confident). Tempering raises the likelihood to β ≤ 1, which widens the posterior without moving its peak, and the ratio falls through 1. Switch to the beam model and it is already calibrated, even conservative, because its ray casts are exact: the over-confidence comes from the map error, not from the multiplication alone. The numbers are this page's own draw.

With the likelihood field and β=1\beta = 1 the posterior's mean is about 2.4 cm from the truth, rms, while it claims a standard deviation of about 1.7 cm: the ratio is about 1.4, over-confident. Tempering to β=0.5\beta = 0.5 brings it to about 0.9 and β=0.3\beta = 0.3 below 1, and the posterior's mean does not move. With the beam model the ratio is already 0.4 to 0.55, and its error 0.3 cm: calibrated, even conservative. That says something specific about where the over-confidence comes from. In this simulator the beams' noise is independent and there is no person to walk through the scan, so the multiplication alone is not the cause; the cause is the rasterized map, whose 5 cm cells bias every beam of the likelihood field in the same way. The beam model casts rays against the exact segments and has no such error. The repository's reason for tempering, correlated beams from unmodeled obstacles, is the real-world version of the same thing, and is described here, not measured, because Rusty has no obstacle that moves.

A different sensor: a landmark

A feature detector that reports an identified landmark gives a range rr and a bearing φ\varphi in the sensor frame. With pp the landmark's position in the sensor frame, the prediction is r^=∥p∥\hat r = \lVert p \rVert, φ^=atan2⁡(py,px)\hat\varphi = \operatorname{atan2}(p_y, p_x), and

p(r,φ∣x,mj)=N(r−r^; 0,σr2)  N(wrap⁡(φ−φ^); 0,σφ2).p(r, \varphi \mid x, m_j) = \mathcal N(r - \hat r;\, 0, \sigma_r^2)\;\mathcal N\bigl(\operatorname{wrap}(\varphi - \hat\varphi);\, 0, \sigma_\varphi^2\bigr).

Everything depends on the word wrap. A bearing is an angle, and the difference of two angles near ±π\pm\pi is not their subtraction: a reading of −3.10-3.10 rad against a prediction of +3.10+3.10 is −6.20-6.20 apart as numbers and 2π−6.20≈0.0832\pi - 6.20 \approx 0.083 rad apart as angles.

w3.7A landmark behind the robotKills the idea that: the difference between two angles is just their subtraction
  • posterior
  • prediction
  • measurement
A beacon two metres behind the robot is seen at a bearing of π, and sensor noise puts the reading on either side of the ±π seam. The model scores a reading by how far its range and bearing are from the ones a hypothesised heading predicts. Subtract the angles plainly, the orange curve, and a reading of −3.10 rad is 6.2 rad from a prediction of +3.10: the likelihood collapses on one side of the seam and the heading looks impossible. Wrap the difference into (−π, π] (the purple curve, the repository's LandmarkModel::residual) and the two are 0.08 rad apart, as they are. Drag the reading across the seam: the wrapped curve moves smoothly and keeps its total mass, the unwrapped one loses mass whenever the reading is within a few hundredths of a radian of it. Nothing here is random: the reading is whatever the slider says.

The Jacobian of the prediction with respect to a right (body-frame) perturbation of the pose is what an EKF on SE(2) needs, and it is short:

DerivationThe landmark Jacobian

Let pbp_b be the landmark in the robot's body frame and p=RsT(pb−ts)p = R_s^{\T}(p_b - t_s) the landmark in the sensor frame, with (Rs,ts)(R_s, t_s) the mount. A right perturbation x⊞δ=xexp⁡(δ)x \bplus \delta = x\exp(\delta), δ=(vx,vy,ω)\delta = (v_x, v_y, \omega), changes the body-frame point to exp⁡(−δ) pb≈pb−(v+ω×pb)\exp(-\delta)\,p_b \approx p_b - (v + \omega\times p_b), so ∂pb/∂δ=(−10pb,y0−1−pb,x)\partial p_b/\partial\delta = \begin{pmatrix} -1 & 0 & p_{b,y} \\ 0 & -1 & -p_{b,x}\end{pmatrix}, and ∂p/∂δ=RsT\partial p/\partial\delta = R_s^{\T} times that. The range and bearing of pp have the Jacobian (px/rpy/r−py/r2px/r2)\begin{pmatrix} p_x/r & p_y/r \\ -p_y/r^2 & p_x/r^2\end{pmatrix} by the chain rule. The product is the 2×32\times 3 matrix, and the repository's test and this book's check compare it with a central difference of the prediction.

crates/robotics_models/src/sensor/landmark.rs
    /// Residual `z ⊟ ẑ = (r − r̂, wrap(φ − φ̂))`.
    pub fn residual(z: &RangeBearing, predicted: &RangeBearing) -> Vector2<f64> {
        Vector2::new(
            z.range - predicted.range,
            angle_diff(z.bearing, predicted.bearing),
        )
    }
// …
    /// Jacobian `∂ĥ/∂δ` of the prediction with respect to a right (body-frame)
    /// perturbation of the pose, `x ⊞ δ = x·exp(δ)`, `δ = (v_x, v_y, ω)` — the `H` an EKF
    /// on SE(2) needs.
    pub fn jacobian(
        &self,
        pose: &Se2,
        landmark: &Vector2<f64>,
    ) -> Result<SMatrix<f64, 2, 3>, ModelError> {
// …
        let p = self.in_sensor(pose, landmark)?;
        let pb = pose.act_inverse(landmark);
        // exp(−δ) p_b ≈ p_b − (v + ω × p_b)  ⇒  ∂p_b/∂δ = [[−1, 0, p_by], [0, −1, −p_bx]]
        let dpb = SMatrix::<f64, 2, 3>::new(-1.0, 0.0, pb.y, 0.0, -1.0, -pb.x);
        let rt = self.sensor.rotation().inverse().into_inner();
        let dp = rt * dpb;
        let r2 = p.norm_squared();
        let r = r2.sqrt();
        let dh = Matrix2::new(p.x / r, p.y / r, -p.y / r2, p.x / r2);
        Ok(dh * dp)
    }
// …
    /// `ln p(z | x, m_j)` (Table 6.4 in log space).
    pub fn log_likelihood(
        &self,
        pose: &Se2,
        landmark: &Vector2<f64>,
        z: &RangeBearing,
    ) -> Result<f64, ModelError> {
        if !(z.range.is_finite() && z.bearing.is_finite()) {
            return Err(ModelError::InvalidData("measurement must be finite"));
        }
        let e = Self::residual(z, &self.predict(pose, landmark)?);
        let (sr, sb) = (self.sigma_range, self.sigma_bearing);
        Ok(-0.5 * ((e.x / sr).powi(2) + (e.y / sb).powi(2)) - (sr * sb).ln() - (TAU).ln())
    }

The model needs the landmark's identity: which landmark a detection belongs to is the data-association problem of Chapter 4, and a detection without one is an error here, not a guess.

The algorithm

AlgorithmBeamModelScan(z, x, m)
  1. o←x∘Tbase→lasero \leftarrow x \circ T_{\text{base→laser}}
  2. for each beam kk: zk∗←raycast(m, o, oθ+φk, zmax⁡)z^*_k \leftarrow \text{raycast}(m,\ o,\ o_\theta + \varphi_k,\ z_{\max}), or zmax⁡z_{\max} if there is no hit
  3. a non-finite zkz_k is read as zmax⁡z_{\max}
  4. return ∑kln⁡p(zk∣zk∗)\sum_k \ln p(z_k \mid z^*_k), each term floored at 10−30010^{-300}
AlgorithmLikelihoodFieldScan(z, x, DistanceGrid)
  1. o←x∘Tbase→lasero \leftarrow x \circ T_{\text{base→laser}}
  2. for each usable beam with zk<zmax⁡z_k < z_{\max}: end point qk=o (zkcos⁡φk, zksin⁡φk)q_k = o\,(z_k\cos\varphi_k,\ z_k\sin\varphi_k)
  3. dk←DistanceGrid.distance(qk)d_k \leftarrow \text{DistanceGrid.distance}(q_k)
  4. return ∑kln⁡(zhitN(dk;0,σ2)+zrand/zmax⁡)\sum_k \ln\bigl(z_{\text{hit}}\mathcal N(d_k; 0, \sigma^2) + z_{\text{rand}}/z_{\max}\bigr)

Putting it together

The problem of the opening, as numbers a run produced. All of them are this page's own draws; the comparison between rows is what holds on both targets, and the build asserts it.

beam modellikelihood field
peak of the score along x, y, from the true posewithin 1 cmwithin 3 cm
width of the peak (within 10 nats of it), field over beamabout 2 times wider (never under 1.7)
work per hypothesised pose360 ray casts, 7 560 segment testsone distance lookup per usable beam
rms error of the posterior mean, over 64 scansabout 0.3 cmabout 2.4 cm
rms of error / claimed σ at β = 1 (1 is calibrated)0.4 to 0.55about 1.4
the same, tempered to β = 0.5below 1about 0.9

And the simulator, in the properties that make every other number in the book reproducible: a seed replays a scan and a whole run bit for bit; removing the lidar changes no other stream; the encoders do not depend on the seed at all.

What the repository says does not work

The limits recorded in docs/SIMULATION.md and docs/SOURCE_MAPPING.md are these. Rusty is not a collision model: the robot is never stopped by a wall, and its arena is 21 static segments, so there is no moving obstacle to correlate the beams, which is why the over-confidence measured here comes from the map and not from people. Ray casting is analytic over every segment, with no spatial index, which is cheaper than maintaining one for 21 segments and is revisited when maps get large. The EM follows the book's table including its untruncated M-steps. The landmark model needs the landmark's identity. And the likelihood field is a score, not a probability of anything: its units are a log density per beam end point, and two numbers from different scans are comparable only because they count the same beams.

Exercises

  1. Foundation: derive exerciseDifficulty 2 of 3Why the normalizer

    Show that phitp_{\text{hit}} integrates to 1 on [0,zmax⁡][0, z_{\max}] and say what would be wrong with the mixture if the normalizer η\eta were left out.

    Solution

    η=Φ((zmax⁡−z∗)/σ)−Φ(−z∗/σ)\eta = \Phi\bigl((z_{\max}-z^*)/\sigma\bigr) - \Phi(-z^*/\sigma) is the Gaussian's mass on [0,zmax⁡][0, z_{\max}], so dividing by it makes the cut-off density integrate to 1. Without it the hit component would lose the mass of the Gaussian's tails outside the interval: little at the middle of the range, and visible near z∗≈0z^*\approx 0 or z∗≈zmax⁡z^*\approx z_{\max}, where the mixture would integrate to less than 1 and a scan taken close to a wall would be systematically under-scored.

  2. Foundation: derive exerciseDifficulty 2 of 3The heading column of the Jacobian

    For a sensor at the robot's origin, derive the third column of the landmark Jacobian, the derivative of (r^,φ^)(\hat r, \hat\varphi) with respect to a heading perturbation ω\omega.

    Solution

    With the sensor at the origin p=pbp = p_b, and ∂pb/∂ω=(pb,y,−pb,x)T\partial p_b/\partial\omega = (p_{b,y}, -p_{b,x})^{\T}. The range is unchanged to first order, because pb⋅(pb,y,−pb,x)=0p_b\cdot(p_{b,y}, -p_{b,x}) = 0, and the bearing changes by (−py py−px px)/r2=−1\bigl(-p_y\,p_y - p_x\,p_x\bigr)/r^2 = -1: turning the robot by ω\omega turns every bearing by −ω-\omega. The column is (0,−1)T(0, -1)^{\T}.

  3. Conceptual: predict, then check exerciseDifficulty 1 of 3Nothing in range
    Predict first

    In the first widget, set the noise and the dropout to 0 and drag the robot to the middle of the arena. Do any beams still read infinity?

  4. Conceptual: predict, then check exerciseDifficulty 2 of 3Which peak is wider
    Predict first

    In the scoring widget, slide the hypothesis along x. Which model's peak is wider, the beam model's or the likelihood field's?

  5. Conceptual: predict, then check exerciseDifficulty 2 of 3Does the beam model need tempering
    Predict first

    In the tempering widget, switch to the beam model at β = 1. Is its posterior over-confident?

  6. Practical: write Rust exerciseDifficulty 2 of 3A room you can score by hand

    In crates/robotics_models/src/sensor/likelihood_field.rs, add a test that builds a DistanceGrid for a 1 m × 1 m room (the occupancy of its four walls), takes the scan of a point sensor at the room's centre, and checks that log_likelihood at the centre is higher than at a pose 10 cm off. Which parameter of the model decides how much higher?

    Solution

    Build the mask with the four walls as occupied cells, call DistanceGrid::from_occupancy, produce a LaserScan whose end points are the walls from the centre, and compare LikelihoodFieldModel::log_likelihood at the two poses. σhit\sigma_{\text{hit}} decides: the score falls as −d2/2σ2-d^2/2\sigma^2 in the exponent, so a narrower σhit\sigma_{\text{hit}} penalizes a 10 cm shift more.

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)

    Chapter 6: the beam model, its parameter learning by EM (Section 6.3.2), the likelihood field (Section 6.4) and the landmark model (Section 6.6); the tables this chapter follows.

  2. Dellaert, F., Fox, D., Burgard, W. and Thrun, S. (1999) Monte Carlo Localization for Mobile Robots. Proceedings of the IEEE International Conference on Robotics and Automation (ICRA), vol. 2, 1322–1328.doi:10.1109/ROBOT.1999.772544 (opens in a new tab)

    The consumer of these models: particle-filter localization, the Monte Carlo half of localization that Chapter 4 points to, and the source of the epigraph.

  3. Felzenszwalb, P. F. and Huttenlocher, D. P. (2012) Distance Transforms of Sampled Functions. Theory of Computing 8(19), 415–428.doi:10.4086/toc.2012.v008a019 (opens in a new tab)

    The linear-time exact Euclidean distance transform the likelihood field is built on; the repository implements it and checks it against brute force.

  4. Dempster, A. P., Laird, N. M. and Rubin, D. B. (1977) Maximum Likelihood from Incomplete Data via the EM Algorithm. Journal of the Royal Statistical Society, Series B 39(1), 1–38.doi:10.1111/j.2517-6161.1977.tb01600.x (opens in a new tab)

    The algorithm behind the learning of the beam model's six parameters.

  5. Bissiri, P. G., Holmes, C. C. and Walker, S. G. (2016) A General Framework for Updating Belief Distributions. Journal of the Royal Statistical Society, Series B 78(5), 1103–1130.doi:10.1111/rssb.12158 (opens in a new tab)

    The generalized-Bayes reading of a weighted update, with a weight on the loss called a temperature: the justification of raising a likelihood to a power β.

  6. Macenski, S., Martin, F., White, R. and Ginés Clavero, J. (2020) The Marathon 2: A Navigation System. IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 2718–2725.doi:10.1109/IROS45743.2020.9341207 (opens in a new tab)

    Nav2, the navigation stack this repository benchmarks against; its AMCL localizer offers the beam and likelihood-field models of this chapter, with the likelihood field as its default.

  7. Chen, X., Läbe, T., Nardi, L., Behley, J. and Stachniss, C. (2020) Learning an Overlap-based Observation Model for 3D LiDAR Localization. IEEE/RSJ International Conference on Intelligent Robots and Systems (IROS), 4602–4608.doi:10.1109/IROS45743.2020.9340769 (opens in a new tab)

    A learned observation model inside Monte Carlo localization, set against the hand-built beam end-point model: the direction a sensor model takes when its parameters are learned from data.

  8. Chen, R., Yin, H., Jiao, Y., Dissanayake, G., Wang, Y. and Xiong, R. (2021) Deep Samplable Observation Model for Global Localization and Kidnapping. IEEE Robotics and Automation Letters 6(2), 2296–2303.doi:10.1109/LRA.2021.3061339 (opens in a new tab)

    A learned model of a 2-D laser scan trained only on simulated observations, which makes the quality of a lidar simulator a research question.

  9. Kuang, H., Pan, Y., Zhong, X., Wiesmann, L., Behley, J. and Stachniss, C. (2025) Improving Indoor Localization Accuracy by Using an Efficient Implicit Neural Map Representation. IEEE International Conference on Robotics and Automation (ICRA), 8027–8033.doi:10.1109/ICRA55743.2025.11128666 (opens in a new tab)

    Replaces the distance transform of a likelihood field with a learned distance network inside Monte Carlo localization: the same idea as this chapter's field, with a different map.