Wheel-slip detection from the odometry residual¶
Mote already carries two independent motion sources: wheel odometry, and kinematic_icp's scan-matched pose. kinematic_icp takes the wheel odom as its prior and corrects it against the scan, so the correction is already a measurement of how wrong the wheels were — a slip signal, on existing hardware, with no IMU. Task 165 established from real bags that the quiet baseline is tight enough for an event to stand out, and that is why the BNO085 is not justified. This note is how that signal became a live health check: what was measured, what the thresholds are, and what fired.
Everything here is reproducible:
# The distribution and the verdicts, over the real mapping bags.
pixi run -- python mote_bringup/tools/slip_replay.py ~/.mote/bags/mapping/*/
# The whole-session survey (path length, yaw, impossible-velocity frames).
pixi run -- python mote_bringup/tools/odom_health.py ~/.mote/bags/mapping/*/
slip_replay.py drives the same ResidualEstimator and classify the
slip_monitor node runs. A threshold calibrated offline only means something if
the robot computes the same number.
The time-sync question, settled¶
Task 165 flagged that odom_health.py resamples 100 Hz wheel odom onto 10 Hz ICP
stamps, and suspected the yaw residual was inflated by stamp skew — at 90 deg/s,
10 ms of skew manufactures ~9 deg/s of apparent disagreement.
Measured, there is no such skew. Shifting the wheel stream by a candidate lag and picking the lag that minimises the yaw residual puts the optimum within ±10 ms on four of the five usable bags (+50 ms on the fifth, which has only 53 moving windows), and the curve is nearly flat there: ±40 ms moves the p50 by under 0.6 deg/s, so no plausible offset accounts for a 3 deg/s residual.
| bag | best lag | yaw p50 at that lag | yaw p50 at zero lag |
|---|---|---|---|
| 20260706_133149 | +50 ms | 2.31 deg/s | 2.61 deg/s |
| 20260706_135320 | +10 ms | 2.72 | 2.85 |
| 20260706_172607 | −10 ms | 2.88 | 2.96 |
| 20260706_192836 | +10 ms | 3.13 | 3.14 |
| 20260706_193037 | −10 ms | 3.04 | 3.05 |
What the yaw residual actually is, is scan-match jitter, which averages down as the comparison window grows while a real slip does not — a slip accumulates displacement. Over the five bags, |yaw| residual p50 falls from ~3.0 deg/s at a 0.1 s window to ~1.1 at 1.0 s and ~0.5–0.9 at 2.0 s.
So the prerequisite is discharged the second way the task allowed: detection is on translation only. The yaw residual is computed and published for logging, so it can be thresholded later if a use appears, but nothing keys off it. The measurements say it could not be: on the same bags and the same windows, the relative yaw residual reaches p99 of 0.86–1.10 (i.e. as large as the yaw rate itself) where relative translation reaches p99 of 0.04–0.53. No yaw threshold exists that a hard turn would not trip.
The estimator also interpolates both streams to the window's own endpoints, rather than resampling one onto the other's stamps, which removes the sampling-grid component of the error outright.
Window length¶
A longer window is quieter, but it also delays detection, and past ~1 s the translation residual has stopped improving much:
| window | 135320 trans p99 | 193037 trans p99 | 172607 trans p99 |
|---|---|---|---|
| 0.5 s | 0.0078 | 0.0111 | 0.0531 |
| 1.0 s | 0.0065 | 0.0073 | 0.0342 |
| 2.0 s | 0.0051 | 0.0098 | 0.0494 |
1.0 s is the choice. At 0.5 s two of the quiet bags already produce single
windows above 0.03 m/s; at 2.0 s the tail does not improve further and detection
latency doubles. Total latency to a reported verdict is the window plus the
hold time, ~1.5 s, which the sim run below shows end to end.
Thresholds¶
A verdict needs the residual to clear both an absolute floor and a fraction of the motion reported. The floor rejects noise at low speed; the fraction rejects a large residual that is merely a large motion measured slightly differently.
Measured over the two bags with no event in them (394 s of driving):
| 20260706_135320 | 20260706_193037 | |
|---|---|---|
| speed residual p50 | +0.0003 m/s | +0.0002 m/s |
| speed residual p99 | +0.0062 | +0.0210 |
| relative p99 | +0.040 | +0.179 |
| stdev | 0.0027 | 0.0042 |
slip_speed: 0.030 m/s sits ~1.4x above the worst quiet p99 and ~7x above the
better one; slip_fraction: 0.25 sits above the 0.179 worst quiet relative p99.
The ICP-fault direction uses the same pair. Independently, an ICP speed above
max_wheel_speed x 1.15 (0.251 m/s) is a fault whatever the wheels say, because
the drive cannot produce it. test_odom_residual.py asserts the thresholds stay
above these measured p99s, so lowering one fails a test rather than quietly
enabling false positives.
These bags are one building, with no glass and no featureless corridor. Treat
the thresholds as provisional until they have been seen on more varied floors;
$MOTE_HOME/slip.yaml overrides them per robot for exactly that reason.
What fired¶
Six verdicts across the six bags, ~38 minutes of recording. Every one was checked
against the recorded /scan_filtered independently of the detector, and every
one is a real event — the bags were less "known-good" than assumed. Two bags
are silent throughout.
| bag | t | verdict | independent evidence |
|---|---|---|---|
| 133149 | 59.1–62.7 s | icp_fault | Wheels report exactly zero translation (in-place rotation); the ICP pose jumps ~12 cm at the onset of the turn. Scan minima do change, so the robot really is rotating. |
| 135218 | 17.5–19.9 s | icp_fault | The robot squeezes past an obstacle — min range falls to 0.127 m and the 90° sector jumps 0.26 → 1.31 m as a corner leaves view. ICP reports 0.356 m/s, above the 0.251 m/s the drive can produce. |
| 172607 | 295.5–297.9 s | icp_fault | ICP reports 0.326 m/s. Impossible for the drive; a scan-match excursion. |
| 172607 | 321.2–326.1 s | icp_fault → slip | Robot stuck. From t=314 the scan is frozen (0.457 / 1.398 / 0.301 m, unchanging) while the wheels report bursts of 0.15–0.22 m/s and ±28 deg/s spins — Nav2's recovery behaviours on a robot that is not moving. |
| 192836 | 46.1–50.7 s | slip | Robot jammed. Scan frozen at min range 0.181 m for ~9 s while the wheels report 0.21 m/s and +15 deg/s. ~1 m of travel and ~80° of rotation the lidar never saw. |
| 135320, 193037 | — | none | 394 s of driving, clean throughout. |
No stream stalled in any of these: /tf and /scan_filtered run at their
nominal 100 Hz / 10 Hz with no gap above 0.5 s, so the frozen scans are the
robot genuinely not moving, not a dropped sensor.
That last distinction turned out to matter enough to change the code. A stalled
lidar looks exactly like slip — the window freezes at the last ICP pose while
the wheels keep turning, so the apparent residual grows without bound. That is
the one failure mode in which this node would blame the wheels for a sensor
dropout, so ResidualEstimator carries an explicit staleness guard: a source
older than max_lag yields no verdict, not a stale one.
Demonstrated live, in the sim¶
sim_launch.py with the wheel_mu friction knob (task 165), the real
slip_monitor running alongside with use_sim_time:=true, driving straight at
0.18 m/s in mote_world.sdf:
| run | result |
|---|---|
wheel_mu:=1.0, 25 s |
Quiet for 20 s — residual p50 +0.0005 m/s. At t≈20 s the robot reaches the wall 3 m away; the residual crosses the threshold at t=20.6 s and reaches +0.179 m/s (relative 1.00 — completely stopped, wheels still reporting 0.18 m/s). Reported as slip at t≈21.2 s. 36 of 125 moving windows over threshold. |
wheel_mu:=0.4, 12 s (no wall contact) |
Residual p50 +0.0002, p99 +0.0099, max +0.0106 m/s; relative max 0.059. Zero windows over threshold. |
wheel_mu:=0.05, 12 s (no wall contact) |
Residual p50 +0.0002, p99 +0.0100, max +0.0107 m/s; relative max 0.059. Zero windows over threshold — indistinguishable from μ=0.4. |
Two things follow. The detector catches an obstruction within ~1 s of contact and
is otherwise silent — the same physical situation as the two real-bag events,
end to end through the real node. And lowering wheel_mu is not on its own a
slip rig: at 0.18 m/s this robot's traction demand is so far below what even
μ=0.05 supplies that the residual is unchanged to three decimal places across a
20x range of friction. The benchmark's --slip flag exercises the pose
pipeline, as its README says; to exercise this node, obstruct the robot.
Note the sim reports slip, not stuck, and correctly so: the wheels are
turning, the robot is not. stuck is the narrower case where the wheels
themselves report nothing despite a command.
What this does not cover¶
- Yaw. Deliberately unthresholded, above. A pure-rotation slip in which translation stays honest would not be caught.
- In-place rotation. Wheel translation is exactly zero during a spin, so the relative term is always −1.0 and only the absolute floor protects the ICP-fault direction. Measured ICP translation during steady ±28 deg/s spins is ≤0.004 m/s, ~7x below the floor, so there is margin — but it is margin, not a structural guarantee.
- One building. See the threshold caveat above.
- Hardware. The live path is proven in the sim and by
test_slip_monitor.py; it has not yet run on the Pi. The detection quality is proven on real robot data through the shared estimator.