Skip to content

Gating kinematic_icp against the drive envelope — evidence

kinematic_icp occasionally emits a pose implying a body speed the drive cannot produce. This is the measurement of whether that matters, where the threshold can sit, and what gating it costs.

Tools: mote_bringup/tools/odom_health.py (scores a bag), icp_excursions.py (characterises each excursion), icp_gate_replay.py (feeds a bag's odometry through the real icp_odom_gate node and records what it broadcasts). Bags: ~/.mote/bags/mapping/, three real mapping sessions. Raw output in 2026-07-28-icp-velocity-gate/.

1. Do the jumps matter?

They are rare — 10 frames across 25 minutes — and always isolated single frames, so the first question was whether the scan match simply re-registers on the next scan and gives the displacement back.

It does not. Wheel odometry is the local reference (it does not drift metres per second, which is what an excursion claims), so the change in the ICP-minus-wheel along-track gap across an excursion separates the two cases: a spike would show the gap returning, a step would show it kept.

                              gap rate before   gap rate after
20260706_192836  7 excursions   +0.0031 m/s      +0.0033 m/s
20260706_133149  3 excursions   +0.0223 m/s      +0.0202 m/s

The rate either side is the same: nothing is given back. Each excursion is a permanent step in odom->base, and therefore in the map frame and in every zone taught in it.

20260706_133149 settles it beyond argument. The robot is stationary for that session — the wheels report 0.5 m of travel in 1218 s — and ordinary intervals show an ICP-vs-wheel gap of |p99| 0.0001 m, i.e. the scan match holds still essentially perfectly. Yet three frames fabricate +0.178 m of displacement, one of them 0.121 m in a single 0.1 s scan (1.2 m/s against a 0.218 m/s limit). Those three frames are 100% of that session's odometry error. Wheel slip cannot explain it in the required direction: slip makes the wheels over-read, never the lidar.

The excursions also correlate with what the robot was doing: in the driving bag they occur at 0.175–0.219 m/s, essentially full speed, and at a median yaw rate of 21.5 deg/s against a session median of 4.8.

So: worth fixing.

2. Where can the threshold sit?

Two candidate bounds, both from robot.yaml's measured max_wheel_speed (0.218 m/s) and wheel_separation (0.22 m).

The joint per-wheel bound |v| + S/2·|w| — the one the Nav2 critic uses — does not work here. In 20260706_192836 the wheel odometry itself exceeds it in 18.98% of intervals, and legitimate ICP intervals reach ×1.44 of the limit while the mildest excursion is also ×1.44. The two populations overlap completely. The cause is the yaw term: ICP is resampled at ~10 Hz and a small stamp misalignment during a fast turn inflates the implied yaw rate (the caveat already recorded in odom_health.py).

Translation and yaw bounded separately do work. Histogramming every interval across all three bags:

legitimate excursions
translation mass ends at 0.230 m/s; one lone sample at 0.245 (t=81.8 s, isolated from every excursion cluster) 0.273, 0.276, 0.283, 0.304, 0.306, 0.355, 0.384, 0.387, 0.433, 1.197
yaw rate max 1.974 rad/s

The band 0.245–0.273 m/s is empty, and max_wheel_speed × 1.15 = 0.251 m/s lands in it: ×1.02 above the highest legitimate sample, ×1.09 below the mildest excursion. That is the same ×1.15 odom_health.py already reports at, so the gate's rule and the health tool's rule are one number rather than two.

The yaw bound is free insurance. The chassis maximum is 2·max_wheel_speed/wheel_separation = 1.982 rad/s, and ICP's fastest measured turn is 1.974 rad/s — right at it, never over. A bound at ×1.15 (2.279 rad/s) therefore never fires on this data but still catches a yaw excursion, which translation alone would miss and which hurts a map more.

3. What the gate does

kinematic_icp broadcasts odom->base itself, and a TF broadcast cannot be retracted, so nothing downstream can undo a bad transform. It is therefore configured to publish only its odometry topic, in a frame of its own (odom_icp), and mote_nav::IcpOdomGate owns odom->base: it accumulates ICP's increments, and where one exceeds the envelope it accumulates the wheel increment for that interval instead.

The wheel increment is the right substitute rather than a clamp: in 20260706_133149 the wheels correctly say "not moving", giving ~0 where a clamp would still have admitted 0.025 m. It is read from TF through the same odom_wheel leaf kinematic_icp takes its own prior from, so the two cannot disagree about what the wheels did; a clamp is the fallback if that lookup fails.

kinematic_icp is unaffected — its prior comes from that TF leaf, never from its own output — so its internal pose keeps the excursion while the published one does not. The gate's track is then permanently offset from ICP's internal track by exactly the excursions it absorbed, and tracks it exactly otherwise. That is the intent, and IcpOdomGate.AbsorbedExcursionLeavesAConstantOffsetNotADrift pins it.

4. Before / after

icp_gate_replay.py feeds each bag's recorded streams to the compiled gate node over ROS and records its broadcasts; odom_health.py then scores the result. Nothing offline re-implements the decision.

                                excursions   icp path    max icp speed
20260706_192836   before             7        10.97 m       0.433 m/s
                  after              0        10.88 m       0.245 m/s
20260706_133149   before             3         0.74 m       1.197 m/s
                  after              0         0.56 m       0.231 m/s
20260706_193037   before             0        13.82 m       0.229 m/s
                  after              0        13.82 m       0.229 m/s

Wheel path length is 11.16 m, 0.54 m and 13.75 m respectively. The gate removes +0.097 m and +0.178 m of fabricated path — exactly the sums measured in step 1 — taking 133149 from +37% over the wheels to −3.8%.

20260706_193037 is the no-regression control. It contains no excursions, and every figure odom_health.py reports for the gated bag is identical to the original, to the last printed digit: path length, yaw travelled, all three residual percentiles, and all six speed percentiles. With nothing to reject the gate is a pure pass-through, which is the claim that matters for normal operation.

5. No regression in normal operation

The gate must not clip legitimate motion. Two checks, both in the sim.

pixi run sim-test passes with the gate owning the edge — 0.600 m commanded forward measured as 0.600 m, 2.0 rad commanded spin measured as 2.001 rad, both read back through the gated odom->base (sim-smoke.log).

bench.py --worlds mote_world.sdf --trials 3, gated against an unmodified worktree at the same commit, run one at a time on an otherwise idle machine:

trials goals ATE rmse (m) per trial mean
baseline 3/3 9/9 0.072, 0.064, 1.173 0.436
gated 3/3 9/9 0.075, 0.069, 0.063 0.069

Every goal succeeds either way, with no aborts and no recoveries: no regression, which is what this run is here to establish.

The gate logged zero rejections across every sim trial, which is the expected result — Gazebo's scan is noise-free and the excursions being gated are a real-sensor phenomenon. It also means the sim cannot demonstrate the gate working; only that it costs nothing when it has nothing to do. Bag replay (§4) is what shows it working.

Read the ATE column with care rather than as a win. The baseline's third trial blew up to 1.173 m with a 55 s middle goal, and a run-to-run spread that wide at n=3 is not something three more trials would settle. What can be said is that the large excursion happened without the gate and that the gated trials cluster tightly; what cannot be said, from this, is by how much the gate improves localisation.

6. Interaction with slip detection (#77)

slip_monitor landed independently and reaches the same threshold from the other direction: it reports an icp_fault when the lidar claims a body speed above max_wheel_speed x 1.15 — the very frames this gate removes. Its own measurements are in 2026-07-28-slip-detection.md, and one of its worked examples (bag 172607, 0.326 m/s) is an excursion of exactly this kind.

The two are complementary, but only if they are wired apart. The monitor read odom->base through TF, which is now the gated edge, where a speed above the envelope cannot occur by construction — so its primary icp_fault branch would have become unreachable, and a scan match degrading behind a working gate would have been reported by nobody while the monitor went on publishing OK.

So kinematic_icp still broadcasts, inverted, as the leaf base -> odom_icp (invert_odom_tf swaps the frame ids as well as the transform, which is what keeps base_footprint from acquiring a second parent), and mote_launch.py points the monitor's odom_frame at it. The monitor therefore still sees the raw track it was validated against — its thresholds were tuned on bags whose odom->base is raw ICP — while navigation runs on the gated edge. The division is: the gate protects the map frame, the monitor reports that the scan match needed protecting.

test_the_slip_monitor_watches_the_ungated_lidar_track pins it, because nothing about getting this wrong is visible at runtime.

Caveats

  • The threshold is calibrated against three bags from one robot on one floor. It is a fraction of a measured hardware constant rather than a magic number, so it moves with robot.yaml, but the ×1.15 slack is empirical.
  • A sustained misregistration — many consecutive frames each individually within the envelope — is not caught by this and would not be. Every excursion measured so far is a single isolated frame.
  • The gate bounds translation and yaw separately. The tighter joint per-wheel constraint is unusable until the ICP and wheel streams are properly time-synced; that is the same blocker as odom_health.py's yaw caveat.