Planning, Navigation & Localization
A map nobody drives on is a screensaver: this chapter is what the robot does with everything the previous chapters built, starting with one contract that unlocks half the questions in this book.
9.1The Frame Contract
If you take one thing from this chapter, take this section. It is the single most senior thing you can say in a navigation interview, and it takes ninety seconds to say well.
map ──(localization / SLAM loop closure)──► odom ──(wheel/VIO odometry)──► base_link
Chapter 0 introduced these frames; now the payoff. Two transforms, two personalities:
- odom → base_link is published by odometry. It is continuous, smooth, and high-rate, and it drifts. It never jumps. Not once, not ever.
- map → odom is published by the localizer or the SLAM system. It is the correction: the difference between where odometry thinks you are and where you actually are. It jumps whenever a loop closure or a relocalization fires, and that is fine, because nothing latency-critical reads it directly.
Why split them at all? Because two consumers want contradictory things. The local controller, the code deciding wheel velocities right now, needs a pose that is continuous: a feedback loop that sees its input teleport 30 cm will command a violent correction for motion that never happened. The global planner needs a pose that is correct, drift included, or its paths start and end in the wrong places. You cannot have both properties in one transform, so the convention gives you two and composes them. The controller lives in odom. The planner lives in map. When loop closure snaps the map, map→odom absorbs the entire jump, the global path replans, and the wheels never feel a thing.
9.2Localization on Yesterday's Map
SLAM built the map. In deployment you usually freeze it and just localize: cheaper, more stable, repeatable, and auditable. The reference recipe is Monte Carlo Localization (MCL), so we go deep on it, then survey the alternatives. (You will also meet it under the name of its best-known implementation, AMCL; the parameter vocabulary below comes from there and has equivalents in every clone.)
Monte Carlo Localization, the Particle Filter You Actually Ship
MCL is the particle filter from Chapter 1 applied to one specific problem: estimate (x, y, θ) against a known 2D occupancy grid. A few thousand pose hypotheses (particles) live in the map. Each cycle: predict every particle forward using odometry plus sampled noise, weight each by how well the live laser scan agrees with the map as seen from that particle, resample so heavy particles multiply and light ones die. The "adaptive" part is KLD sampling: the particle count itself shrinks when the cloud is tight and confident, and grows when it is spread out and unsure, so you pay compute only for uncertainty.
An MCL implementation has dozens of parameters and about ten that matter. Learn these and you can tune any of them (Setup B builds the whole filter from scratch so none of them stay abstract):
| Parameter | What it controls | Symptoms when wrong |
|---|---|---|
min_particles / max_particles | Bounds on the KLD-adapted particle count (defaults 500 / 2000). | Too few: the true pose loses its particles in ambiguous areas. Too many: CPU burn with no accuracy gain. |
laser_model_type | likelihood_field (default, fast, smooth) vs beam (models max-range and unexpected-obstacle physics, costlier). | Likelihood field degrades gracefully with clutter; beam is brittle unless its z-weights are tuned. |
z_hit, z_rand, sigma_hit | Mixture weights of the measurement model: how much you trust a beam that matches the map vs pure noise. | Overconfident z_hit makes the filter collapse onto locally good, globally wrong matches. |
alpha1–alpha4 | Odometry noise: rotation from rotation, rotation from translation, translation from translation, translation from rotation. | Too small: the cloud can't spread enough to track real slip and diverges. Too large: the cloud smears and the pose gets soft. |
update_min_d / update_min_a | Motion thresholds (0.25 m / 0.2 rad by default): no filter update until the robot has moved this much. | Set to zero "for accuracy" and you resample constantly while standing still, starving diversity. |
recovery_alpha_slow / recovery_alpha_fast | Two exponential averages of particle weights; when short-term likelihood drops well below long-term, AMCL injects random particles (0.0 disables; try 0.001 / 0.1). | Disabled: a delocalized robot stays delocalized forever. Too aggressive: random particles constantly dilute a good estimate. |
Global Relocalization and the Kidnapped Robot
Power the robot on anywhere, or carry it while it's off (the classic "kidnapped robot" test). Triggering global relocalization (every MCL implementation exposes the switch) scatters particles uniformly over free space and lets a few seconds of driving kill the wrong ones. It works when the environment is distinctive and fails in symmetric ones: two identical corridors produce a genuinely multimodal belief, two blobs of probability, and only driving past something asymmetric resolves it. Particle filters can represent two blobs; a Kalman filter, being a single Gaussian, cannot. That is the whole reason Monte-Carlo methods own global localization.
- Kidnapped robot problem
- The robot is moved without being told; the localizer must notice its belief is wrong and recover globally, not just track.
- Particle deprivation
- Resampling killed all particles near the true pose. Nobody is left to be right; the filter is confidently wrong.
- KLD sampling
- Adapting the particle count so the sample-based belief stays within a KL-divergence bound of the true posterior. The A in AMCL.
- Likelihood field
- A precomputed distance-to-nearest-obstacle grid; scoring a beam becomes one lookup instead of a raycast.
The Alternatives, and How to Pick
| Map you built | Localizer | Mechanics |
|---|---|---|
| 2D occupancy grid (Chapter 3) | MCL | Particle filter against the grid, as above. Default answer for 2D. |
| 2D pose graph (Chapter 3) | Localization mode of the mapper | Scan-to-graph matching with a small rolling buffer of temporary nodes; snappier than MCL in geometry-rich spaces, no particle machinery to tune. |
| LiDAR point map (FAST-LIO2, Chapter 3) | Scan-to-map ICP/NDT registration (Chapter 3's matchers against the saved cloud) | Register each live scan against the saved cloud, seeded by an IMU-predicted guess. Accurate; needs a decent initial pose. |
| RTAB-Map database (Chapter 5) | Localization mode (Mem/IncrementalMemory=false) | Bag-of-words appearance retrieval plus PnP publishes the map→odom correction; proximity links refine on re-traversal. |
| Visual keyframe map (Chapter 4) | DBoW2 relocalization, or hloc-style retrieval + PnP | Match live features to the keyframe database. Camera-only relocalization sees the posters a lidar cannot. |
| Neural / 3DGS map (Chapter 7) | Render-and-compare pose optimization | Research-stage; not what you say you'd ship this year. |
The picking rule is short: match the localizer to the map you actually have and the sensor you actually trust. If you built a 2D grid and carry a planar lidar, run MCL and spend your week elsewhere. Reach for scan-to-map ICP when you need centimeter repeatability, and for visual relocalization when geometry is ambiguous but appearance is not.
9.3From Map to Plannable Structure
Planners do not read occupancy grids; they read costmaps. A costmap is built by stacking layers, each painting cost into a shared grid:
- Static layer: the SLAM map from disk. Walls that existed yesterday.
- Obstacle layer: live sensor marking and clearing via raycast. Things that exist right now.
- Voxel / spatio-temporal layer: 3D-aware versions. A voxel layer keeps a column of voxels per cell and projects down; the spatio-temporal variant (Macenski's STVL design) replaces raycast-clearing with time-based decay, which scales to 3D lidars and depth cameras without eating a CPU core.
- Inflation layer: paints a decaying cost skirt around every lethal cell, so "legal but uncomfortable" exists as a concept the planner can trade off.
The inflation math is worth knowing exactly, because it explains the most mis-set parameter in robot navigation. A cell containing an obstacle costs 254 (lethal). Any cell closer than the robot's inscribed radius costs 253: the center being there guarantees a collision, no orientation can save you. Beyond that, cost decays exponentially:
cost(d) = 252 · exp( −cost_scaling_factor · (d − inscribed_radius) ) for d > inscribed_radius,
until d > inflation_radius, where cost = 0
Why is inflation_radius the most mis-set parameter in navigation? Because people set it to "about the robot radius" and stop, which produces a costmap that is lethal-or-free with almost no gradient in between. Every planner and every controller critic in the sections below reads this cost surface. With no skirt, A* happily plans one cell away from walls, MPPI's cost critic has nothing to push against until it is already too late, and your robot hugs walls with total confidence. The standard guidance is the opposite of the folk habit: inflate generously (well past the robot radius) with a gentle cost_scaling_factor, so cost decays smoothly across most of the free space and everything downstream gets a usable gradient.
One more structural fact: there are two costmaps. The global costmap covers the whole map in the map frame and feeds the planner. The local costmap is a small rolling window (a few meters square) centered on the robot, usually in the odom frame, updated fast, and feeds the controller. This is the frame contract again, in costmap form: the global one is allowed to be big, slow, and jump-corrected; the local one must be small, fast, and smooth.
For 3D platforms the plannable structure changes representation: project OctoMap down to 2.5D, or build a TSDF→ESDF distance field (Voxblox, nvblox, FIESTA, all from Chapter 3) so every point in space knows its distance and direction to the nearest obstacle.
9.4Global Planners
Dijkstra expands cells from the start, cheapest total cost first, and is complete and optimal on the grid. A* adds a heuristic, an optimistic guess of remaining cost (straight-line distance, usually), and expands best-guess-first instead. Same guarantees if the heuristic never overestimates, far fewer cells touched. That is the entire difference; everything else in this section is about what "the grid" fails to capture.
The lineage, oldest first:
- The wavefront planner: the legacy default of every stack, a Dijkstra (optionally A*) flood over the costmap with gradient descent to extract the path. Reliable, fast, and it treats your robot as a holonomic point.
- Cost-aware A*: a modern A* over the same grid that trades path length against accumulated cell cost, with smoothing built in. The drop-in upgrade for differential-drive robots that can turn in place, and what Setup A implements.
- Hybrid-A*: search in (x, y, θ) where every expansion is a motion primitive, a short Dubins or Reeds-Shepp arc the vehicle can physically drive. Periodically it attempts an analytic expansion: shoot one exact Dubins/RS curve straight at the goal, and if it is collision-free, finish early. This is the direct descendant of the DARPA Urban Challenge planner and the answer for anything car-like.
- State Lattice: like Hybrid-A* but the primitives come from a precomputed lattice generated for your kinematics, including omni and legged-ish motion models, instead of hardcoded car curves.
The phrase to internalize: grid-feasible is not drivable. A holonomic grid planner will cheerfully produce a path with a 90° corner in a hallway for a robot with a 1.2 m turning radius. Every cell of that path is free space; no continuous motion of that vehicle can follow it. Kinematically constrained platforms need the constraint inside the search, not smoothed on afterward, which is exactly what Hybrid-A* and State Lattice do.
Off the grid entirely: sampling-based planners (RRT, RRT*, PRM, in OMPL and MoveIt) grow a random tree through free space instead of discretizing it. They are the answer for high-dimensional problems; a 7-joint arm lives in 7-D, where grids explode exponentially. RRT* is asymptotically optimal, meaning the path keeps improving as samples accumulate.
9.5Local Planners and Controllers
The global path is a suggestion. The controller turns it into velocity commands at 20 Hz while dodging whatever the map didn't know about. Four names cover the field:
- DWA, the Dynamic Window Approach (Fox, Burgard, Thrun, 1997): sample reachable (v, ω) pairs from the current velocity's "dynamic window", forward-simulate each for a second or two, score the arcs with critics (progress, clearance, path distance), pick the winner. Later implementations made the critics pluggable. Legacy, but the vocabulary (rollout, critic, scoring) survives in everything since.
- TEB, Timed Elastic Band (Rösmann et al.): treat the local trajectory as an elastic band of poses with timestamps and optimize it as a sparse graph under kinodynamic constraints, obstacles pushing, the path pulling, time shrinking. True trajectory optimization, excellent for car-like footprints, and famously fiddly: it optimizes within one homotopy class and can oscillate when two ways around an obstacle score similarly.
- MPPI, Model Predictive Path Integral (Williams et al., ICRA 2017), the modern default for wheeled robots: every cycle, sample a batch of control sequences (thousands of rollouts,
batch_size×time_steps), simulate each through the motion model, score each rollout with a sum of critics (cost functions: obstacle cost, path alignment, path following, goal, prefer-forward, constraint limits), then blend the rollouts into one command with softmax weighting controlled bytemperature. Low temperature acts like argmax over rollouts, sharp but jittery; higher temperature averages more broadly and smooths. No gradients required, so critics can be anything, and the sampling is embarrassingly parallel. - Regulated Pure Pursuit (Macenski et al., after Coulter's 1992 pure pursuit): chase a lookahead point on the path, with speed regulated down by curvature and obstacle proximity. It does not avoid obstacles; it tracks a path exactly. That exactness is why it wins at docking, row-following, and anywhere the path is the specification.
| Controller | Mechanism | Wins when | Honest weakness |
|---|---|---|---|
| DWA | Sampled velocity arcs + critics | Simple diff-drive, low CPU budget | Short horizon; arcs only; dated |
| TEB | Elastic-band graph optimization | Car-like kinematics, tight maneuvers with reversing | Homotopy flipping, many knobs, heavy per-cycle |
| MPPI | Sampled control rollouts, critic-weighted blend | Cluttered, dynamic spaces; arbitrary cost terms | Needs CPU; tuning is critic-weight whack-a-mole until you're systematic about one-knob experiments |
| Regulated Pure Pursuit | Geometric path tracking with speed regulation | Docking, aisles, crop rows, any exact-path job | No avoidance at all; garbage path in, garbage motion out |
The senior framing: DWA and TEB are optimizers over a small structured space (arcs, one band); MPPI is a sampler over an unstructured one. Sampling costs compute and buys robustness to non-convex, fast-changing cost surfaces, which is precisely what a cafe full of chair legs and ankles is.
9.6Behavior Trees Run the Show
Above the planners sits orchestration, and the industry pattern is a behavior tree, typically built on BehaviorTree.CPP, a plain C++ library with no framework attached. A production navigation tree reads roughly as: replan the global path once a second, follow it continuously, and if either fails, walk a recovery sequence. The standard recoveries: clear costmaps (forget possibly-stale obstacles), spin (rotate in place to refresh perception), wait (the world may unblock itself; people move), back up (get out of a lethal pocket). Each recovery is followed by a retry of the thing that failed.
Why did BTs replace the state machines that ran the previous generation of navigation stacks? Because recovery logic is combinatorial. A finite-state machine encoding "if planning fails twice, clear the global costmap, but if following fails, first wait then spin, unless we already spun" turns into a transition-table hairball nobody can review. A BT expresses the same policy as a readable tree of fallbacks and sequences, each node reusable, the whole thing swappable per robot via one XML file, no recompilation. You can hand a fleet operator a different recovery policy as a config change.
Two extensions you should be able to sketch in an interview: waypoint following (a loop node wrapping navigate-to-pose, with a task hook at each stop: take a photo, wait for confirmation, charge) and custom recoveries (write a new BT action node in C++, insert it into the XML; a common one is "call for help", which pings a human and blocks until teleop releases it).
Setup A: a Costmap and a Cost-Aware A*, from Scratch. Goal: build sections 9.3 and 9.4 with your own hands: load an occupancy map, inflate it with the exact cost profile of Fig 9.2, and plan with a cost-aware A*, all in one Python file. Prereqs: pip install numpy scipy matplotlib pillow, plus any occupancy-grid image (the PGM you exported from Chapter 3's Open3D setup, or any floor-plan PNG where dark pixels are walls).
import heapq
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
from scipy.ndimage import distance_transform_edt
RES, R_INS, R_INFL, K = 0.05, 0.22, 0.55, 3.0 # m/cell, radii, decay
occ = np.array(Image.open('map.pgm').convert('L')) R_INS) & (d = 253: continue # lethal or inscribed
ng = g[cur] + step + w_cost * cost[nxt] / 252
if ng < g.get(nxt, np.inf):
g[nxt], came[nxt] = ng, cur
heapq.heappush(pq, (ng + h(nxt), nxt))
path = astar(cost, (40, 35), (410, 520))
plt.imshow(cost, cmap='magma'); ys, xs = zip(*path)
plt.plot(xs, ys, lw=2, color='cyan'); plt.savefig('plan.png', dpi=150)
What you should see: the costmap rendered with the lethal core, the bright inscribed plateau, and the exponential skirt around every wall, and a path that rides the valley of low cost through the middle of corridors rather than kissing the walls.
Break it on purpose: (1) Set w_cost=0: pure shortest-path A*, and the path immediately hugs every corner it can cut. That is section 9.10's first war story, self-inflicted. (2) Set R_INFL = R_INS: the skirt vanishes, the cost surface goes lethal-or-free, and even w_cost=5 cannot help, because there is no gradient left to follow. (3) Double R_INS and watch doorways close: the "no path found" symptom, reproduced. (4) Time it; then halve RES and time it again to feel the quadratic cost of resolution.
9.7Safety Is a Separate Layer
The planner is not the safety system. Say that sentence early in any interview that touches deployment. The navigation stack is where you put intelligence; safety goes in layers below it, each simpler and more auditable than the one above:
- A collision monitor: a thin process between the controller and the base that watches raw sensor data against configured polygons (a stop zone, a slowdown zone, an approach zone that scales speed by time-to-collision) and clamps or zeroes the velocity command regardless of what the controller wanted. Software, but simple software, with no map, no planner, no belief.
- Certified lidar safety fields: safety-rated scanners (SICK, Hokuyo UAM) evaluate configurable protective fields inside the sensor and open a hardware output when violated. No software stack in the loop. This is what a safety audit actually accepts.
- The e-stop chain: a hardware circuit (safety relays, STO inputs on motor drivers) that removes torque. It must function with every computer on the robot crashed. If your e-stop is a software message, you do not have an e-stop.
These layers exist because they answer at different timescales, which is the shape of the standard interview question: a person steps in front of the robot. The safety field reacts in milliseconds, independent of all software above it. The controller reacts within a control cycle, tens of milliseconds, steering rollouts away from the new cost. The costmap and global replan react in hundreds of milliseconds if the route must change entirely. Safety is the layer you certify; the other two are the layers you tune.
9.8SLAM and Navigation, Coupled Honestly
The clean story is "SLAM makes the map, navigation uses it". Real deployments are messier, and the mess is where senior candidates separate from junior ones:
- Mapping while navigating. People walking by get baked into the map as phantom walls. Keep the static layer (the SLAM map) separate from a live obstacle layer with time-based decay, and gate any map updates on repeated, confident observation.
- Loop closure mid-plan. Handled, if you honored §8.1. The map jumps, map→odom absorbs it, the global path replans, the controller never flinches. Violate the contract (plan or control against map-frame poses directly) and the same event produces a robot that lunges sideways. §8.10 has the war story.
- Frozen maps vs lifelong mapping. A frozen map is stable, testable, certifiable, and wrong within a month in any warehouse where pallets move. Lifelong mapping (continued pose-graph sessions from Chapter 3, RTAB-Map's incremental memory from Chapter 5) keeps the map current at the cost of a moving baseline: yesterday's regression test ran on a map that no longer exists. Most products freeze the map and schedule re-mapping, because auditability beats freshness until the environment churns fast enough to force the issue.
- Multi-session. Merging Tuesday's map with Wednesday's requires anchoring both to a shared frame: relocalize in the old map first, then extend it. RTAB-Map does this natively; 2D pose-graph mappers do it via serialized graphs. The failure smell is a map file that grows without bound while quality degrades, because sessions were appended without loop closures binding them.
- No loop closures available. Short drone sortie, GPS-denied sprint, disposable inspection run: do not pretend. Plan entirely in the odom frame and accept bounded drift. Common, correct, and worth saying out loud because it shows you know the map frame is optional.
- Where goals come from. Trace it end to end on a product: operator clicks a map tile in the fleet UI → goal pose in the map frame → navigate-to-pose request → behavior tree → planner → controller → velocity command → base driver. Owning that trace, with the frames named at each hop, is a complete interview answer by itself.
- Learning-based navigation, one sentence of currency: navigation foundation models (ViNT, NoMaD) and RL local planners are promising, and production in 2026 is still a classical stack with learned perception feeding it (Chapter 7's trade-offs apply unchanged).
9.9Drones and 3D in One Honest Section
Same skeleton, different flesh. A quadrotor still has localization, a world representation, a global plan, a local plan, and a safety layer; every box just changes contents. The costmap becomes an ESDF (Voxblox, nvblox, FIESTA), because a drone needs true 3D clearance and, more usefully, the gradient of clearance, which pushes trajectories away from obstacles during optimization.
The planning trick that makes aerial trajectories tractable is differential flatness: quadrotor dynamics are such that position and yaw trajectories, with their derivatives, determine the full state and inputs, so you can plan a smooth curve in space and recover attitude and thrust afterward. The classic realization is minimum-snap polynomial trajectories (Mellinger & Kumar, ICRA 2011): piecewise polynomials through waypoints, minimizing the fourth derivative of position, because snap maps to control effort. In clutter, the modern local planner is EGO-Planner, which cleverly avoids building the full ESDF and optimizes B-spline trajectories against only the obstacles that actually conflict, with Fast-Planner as the ESDF-based alternative lineage.
When asked to compare stacks, resist the urge to describe two unrelated systems. Walk the same five boxes twice: VIO instead of wheel odometry, ESDF instead of costmap, kinodynamic polynomial search instead of Hybrid-A*, geometric tracking controller instead of MPPI, and a certified height/geofence layer instead of lidar safety fields. Interviewers reward the parallel structure because it is how people who have built both actually think.
9.10Tuning War Stories
Five failure signatures, each with the cause and the knob. These are the ones that eat afternoons.
The robot hugs walls. Paths run one cell from every obstacle and the base occasionally clips corners. Cause: inflation is too small or decays too fast, so the cost surface is lethal-or-free and nothing rewards staying central (§8.3). Fix: raise inflation_radius well past the robot radius (0.55 m on a 0.22 m robot is a sane start, more in wide spaces), soften cost_scaling_factor, and confirm the planner is cost-aware (a travel-cost multiplier ≥ 2 in a cost-aware A*, not a wavefront planner that ignores cost).
The robot oscillates in doorways. It lines up, wobbles left-right, sometimes retreats and retries. Two compounding causes: the controller flip-flops between passing homotopies (TEB's signature disease; MPPI does it too when path critics fight obstacle critics at equal strength), and the costmap resolution is too coarse for the gap, so a 0.80 m door at 0.05 m cells with generous inflation is intermittently "closed" as the robot's pose quantizes. Fix: drop local costmap resolution to 0.025 m, trim the local inflation to robot radius plus a small margin, and re-balance path-follow vs obstacle weights (Setup B).
The robot spins in place forever. The spin recovery fires, fails to help, the BT retries, the spin fires again. A recovery loop means the underlying failure is permanent: usually a phantom obstacle marked in the costmap that clearing never removes (a sensor artifact re-marking every cycle, or clearing disabled on the observation source). Watch a live render of the local costmap while it spins; if a blob persists with nothing physically there, fix the sensor mask or decay, not the BT.
The robot overshoots the dock. Regulated Pure Pursuit sails past the target and corrects backward. Cause: lookahead_dist is tuned for cruising, so near the dock the carrot sits beyond the target and the regulation never bites. Fix: scale the lookahead with velocity, give it a small minimum, and lower the approach speed; docking wants a short leash.
Navigation diverges after every loop closure. Fine for ten minutes, then one loop closure and the robot lunges half a meter sideways. Someone violated the frame contract: a node is planning or controlling against map-frame poses directly (a custom goal relay, a hand-rolled path follower reading the localizer's map-frame pose stream). The jump that map→odom exists to absorb is being fed straight into a feedback loop. Find the node reading map-frame pose at high rate and move it to odom, exactly as §8.1 prescribes.
Setup B: Monte Carlo Localization, from Scratch. Goal: implement section 9.2 completely: a particle filter localizing a simulated robot on the Setup A map, with a likelihood field, resampling, and a kidnapped-robot recovery you trigger yourself. Prereqs: the Setup A file (reuse occ and the distance transform d, which is the likelihood field).
rng = np.random.default_rng(3)
free = np.argwhere(~occ)
N_BEAMS, Z_HIT, SIGMA = 8, 0.8, 0.25 # the z_hit / sigma_hit knobs
def raycast(pose, n=N_BEAMS, max_r=6.0): # simulated lidar endpoints
x, y, th = pose
angs = th + np.linspace(0, 2*np.pi, n, endpoint=False)
pts = []
for a in angs:
for r in np.arange(0.1, max_r, RES):
px, py = x + r*np.cos(a), y + r*np.sin(a)
i, j = int(py/RES), int(px/RES)
if not (0 <= i < occ.shape[0] and 0 <= j < occ.shape[1]) or occ[i, j]:
break
pts.append((px, py))
return np.array(pts)
def weight(particle, scan_pts_body): # likelihood-field model
x, y, th = particle
c, s_ = np.cos(th), np.sin(th)
w = 1.0
for bx, by in scan_pts_body: # beam endpoints into map frame
mx, my = x + c*bx - s_*by, y + s_*bx + c*by
i, j = int(my/RES), int(mx/RES)
if 0 <= i < d.shape[0] and 0 <= j < d.shape[1]:
w *= Z_HIT * np.exp(-d[i, j]**2 / (2*SIGMA**2)) + (1-Z_HIT)*0.1
else:
w *= 0.05
return w
# init: N particles scattered over free space (global localization)
N = 2000
P = np.column_stack([free[rng.integers(0, len(free), N)][:, ::-1] * RES,
rng.uniform(-np.pi, np.pi, N)])
truth = np.array([2.0, 2.0, 0.0])
for step in range(120):
u = np.array([0.06, 0.0, 0.03]) # forward + slight turn
truth += [u[0]*np.cos(truth[2]), u[0]*np.sin(truth[2]), u[2]]
delta = raycast(truth) - truth[:2] # endpoints, map-aligned
ct, st = np.cos(-truth[2]), np.sin(-truth[2]) # rotate into body frame
scan = np.column_stack([ct*delta[:,0] - st*delta[:,1],
st*delta[:,0] + ct*delta[:,1]])
P += np.column_stack([u[0]*np.cos(P[:,2]), u[0]*np.sin(P[:,2]),
np.full(N, u[2])]) # predict
P += rng.normal(0, [0.01, 0.01, 0.008], P.shape) # motion noise (alphas!)
W = np.array([weight(p, scan) for p in P]); W /= W.sum()
n_eff = 1.0 / np.sum(W**2)
if n_eff < N/2: # adaptive resampling
P = P[rng.choice(N, N, p=W)]
est = np.average(P, axis=0, weights=W)
print(f'{step:3d} spread {P[:,:2].std():.2f} m err {np.hypot(*(est[:2]-truth[:2])):.2f} m')
What you should see: the particle spread collapses from meters (scattered everywhere) to a few centimeters within the first few dozen steps as beams kill wrong hypotheses, and the error column settles near the motion-noise floor. If the building has two similar corridors, watch the spread stay bimodal until the robot passes something distinctive: multimodality, live.
Break it on purpose: (1) Kidnap the robot: at step 60, add 3 m to truth without telling the filter. The weights collapse, and the filter stays confidently wrong, because vanilla MCL cannot recover. Then implement the recovery knob yourself: when the mean weight drops far below its long-term average, replace 10% of particles with uniform random ones. That is recovery_alpha_slow/fast, demystified. (2) Set motion noise to zero and watch the cloud collapse prematurely and diverge: the alpha parameters, demystified. (3) Drop to 2 beams: ambiguity everywhere. (4) Raise SIGMA to 1.0: everything matches everything, the pose goes soft.
9.11Reading List
The Marathon 2: A Navigation SystemThe reference architecture this chapter's concepts come from: BT orchestration, layered costmaps, and marathon-length validation. Planning AlgorithmsThe planning bible, framework-free: grids, sampling, kinodynamics, everything in sections 9.4 and 9.5 with proofs. Probabilistic Robotics, Chapter 8 (Monte-Carlo Localization)AMCL explained by its authors. Skim chapter 5 (motion models) to understand the alpha parameters properly. Information Theoretic MPC for Model-Based Reinforcement Learning (MPPI)The sampling-and-reweighting idea under Nav2's default controller; the Nav2 MPPI docs cover the practical side. BehaviorTree.CPPThe behavior-tree engine of section 9.6, usable in any C++ program; the docs teach BT design better than most papers. Practical Search Techniques in Path Planning for Autonomous Driving (Hybrid A*)Where heading-aware grid search comes from; the analytic expansion trick is already here. The Dynamic Window Approach to Collision AvoidanceThe 1997 paper whose rollout-and-score vocabulary every controller since still speaks. Timed Elastic Band (TEB) Local PlannerTrajectory optimization as a sparse graph; read the homotopy-class handling to understand its doorway oscillation. Spatio-Temporal Voxel Layer: a View on Robot Perception for the Dynamic WorldTime-decay clearing instead of raycasting; the costmap idea that made 3D sensors affordable on a CPU. Minimum Snap Trajectory Generation and Control for QuadrotorsThe aerial-planning classic: differential flatness plus polynomial trajectories. EGO-Planner: An ESDF-free Gradient-based Local Planner for QuadrotorsThe modern drone-in-clutter answer; contrast with ESDF-based Fast-Planner. Voxblox: Incremental ESDF ConstructionHow distance fields are built incrementally; the bridge between Chapter 3's maps and this chapter's optimizers.9.12Interview Questions
A robot must navigate an office it mapped yesterday. Walk me through the full stack.
Load the saved map, localize against it (MCL on the grid, or RTAB-Map/hloc relocalization if the map is visual), maintaining map→odom. Build layered costmaps: static, live obstacle, inflation. Plan globally with cost-aware A* or Hybrid-A* in the map frame; track locally with MPPI in the odom frame; let a behavior tree orchestrate replanning and recoveries; and below everything, an independent safety layer that can stop the base no matter what the software believes. Close by stating the frame contract, smooth odom for control and jumpy-but-correct map for planning, and you have delivered a complete senior answer.
MCL's particles all converge on the wrong corridor. Why, and how do you fix it?
The environment is symmetric: two corridors look identical to the laser, so the true posterior is multimodal, and aggressive resampling starved the mode that happened to be correct (particle deprivation). Fixes: more particles and gentler resampling to keep minority modes alive, enable the recovery alphas so random particles get injected when likelihood collapses, trigger global relocalization, or add a sensor that breaks the symmetry; a camera-based relocalizer sees the posters on the wall that the lidar cannot.
Why can't the local planner just work in the map frame directly?
Because the map-frame pose is allowed to jump whenever localization corrects itself. A controller mid-maneuver that sees its pose teleport 30 cm will command a violent correction for motion that never physically happened. The odom frame is guaranteed continuous; it drifts, but a feedback controller tolerates slow drift far better than discontinuity. Accuracy for the planner, continuity for the controller: two frames, one contract.
The grid A* path is jerky. What are your options?
Three tiers. Post-process: run a smoother over the path. Plan better: use a kinodynamic planner (Hybrid-A* or State Lattice) whose expansions are drivable curves in the first place. Change representation: optimize the trajectory in an ESDF, where the distance gradient pulls the path smooth and clear of obstacles simultaneously. Bonus points for noting that the controller (MPPI or TEB) also smooths execution regardless of what the planner emitted.
A person steps in front of the robot. Which layer reacts, and at what latency?
Three timescales, know all three. The safety layer (certified lidar field or collision monitor) reacts in milliseconds, independent of the software above it. The local controller reacts within a control cycle, tens of milliseconds, as its rollouts hit the person's cost and it steers around. The costmap obstacle layer and a global replan react in hundreds of milliseconds if the route must change entirely. Safety is the layer you certify; the others are the layers you tune.
How would a drone's and a ground robot's nav stacks differ, on the same map?
Representation: the UGV plans on a 2.5D costmap; the drone needs true 3D, an ESDF. Planner and controller: A*/Hybrid-A* plus MPPI under wheel kinematics, vs differential-flatness min-snap polynomials or EGO-Planner tracked by a geometric controller. Localization sources differ too: wheel odometry plus lidar vs VIO plus GPS or lidar. Same skeleton at every layer (localize, represent, plan, track, protect), different flesh in each, and presenting it as the same skeleton is what shows architectural thinking.
Why is inflation radius the most impactful single parameter in the stack?
Because it shapes the cost surface that everything downstream reads: the global planner's cost-aware search, every MPPI critic, even where recoveries think free space is. Too small and the costmap is binary, so paths hug walls and the controller gets no gradient until collision is imminent. Too large and doorways close and no path exists. It also couples with cost_scaling_factor, robot footprint, and costmap resolution, so it silently interacts with four other knobs. The practiced answer: inflate generously with gentle decay so most free space carries an informative gradient, then tune controllers on top of that surface, never before it is settled.
MPPI or TEB for a cluttered cafe, and why?
MPPI. A cafe's cost surface is non-convex and fast-changing (chair legs, ankles, bags), and TEB optimizes a single elastic band within one homotopy class, so it flip-flops between passing sides and oscillates when alternatives score similarly. MPPI samples thousands of rollouts per cycle across all nearby homotopies, needs no gradients so its critics can encode arbitrary costs, and degrades gracefully as clutter moves. TEB still earns its keep on car-like platforms doing tight reversing maneuvers, where its explicit kinodynamic constraints are the point. The honest trade: MPPI buys robustness with compute.
What actually breaks if map→odom is published at only 1 Hz?
Less than people expect, and the reason is the design itself: the transform chain composes map→base_link from the latest slow correction and the fast odom→base_link, so between corrections the pose stays available and merely drifts slightly. The controller is untouched, since it lives in odom. What breaks: if a consumer's transform timeout is smaller than the update gap, lookups start failing and costmaps or planners drop updates; and each correction now absorbs a full second of drift, so map-frame poses staircase, which degrades global replanning near tight spaces. The senior framing: map→odom is a low-rate correction by design, and consumer timeouts must be sized to match its actual rate.
· End of the Book: You Now Own the Skeleton ·
Go back through the interview questions in every chapter and answer them out loud, cold. When you can do that, the word in the title is yours.