Localisation
Docs index · Fault modes · Design notes
Does the robot still know where it is when the sensors lie to you?
The stack is an ordinary one — robot_localization’s EKF with
navsat_transform in front of it — wired the real way, with nothing stubbed.
What is unusual is what sits beside it: a shim that breaks the sensors on cue,
and a scorer that grades the estimate against ground truth rather than against
itself.
1. The pipeline
flowchart LR
sim[simulator<br/>O3DE or headless] -->|/gnss/fix<br/>/imu/data<br/>/wheel/odom| inj[fault_injector]
inj -->|.../faulted| local[EKF local<br/>owns odom -> base_link]
inj -->|.../faulted| ekf[navsat_transform<br/>+ EKF global<br/>owns map -> odom]
ekf -->|/odometry/filtered| scorer
sim -->|/ground_truth/odom| scorer
runner[scenario_runner] -->|/cmd_vel| sim
runner -->|set_fault| inj
scorer --> report[(JSON report)]
The injector is a shim between the sensors and the filter. Nothing in the simulator or the filter knows it exists, which is what lets the same scenarios run against O3DE and against the headless model.
Two filters, which is what robot_localization is built for. The local one
fuses everything except GNSS and publishes odom → base_link, so that frame
stays continuous and never jumps. The global one adds GNSS through
navsat_transform and publishes map → odom; it is the one the scorer reads.
Both run in 2D mode at 30 Hz.
The local filter’s sensor set is derived from the profile rather than written
out separately, so the two cannot drift apart — and so that naive stays a
control case. A local filter that fused a gyro naive does not would put a
heading into odom → base_link that the profile is supposed to prove it lacks.
2. The three profiles
ros2 launch kraken_localisation localisation.launch.py profile:=robust.
Everything else about the stack is identical between them, which is what makes
the comparison a measurement rather than a claim.
| profile | wheel forward speed | lateral constraint | GNSS | IMU yaw rate | ground speed |
|---|---|---|---|---|---|
naive |
✓ | ✓ | ✓ | ||
robust |
✓ | ✓ | ✓ | ✓ | |
radar |
✓ | ✓ | ✓ | ✓ | |
crosscheck |
cross-checked | ✓ | ✓ | ✓ | ✓ |
naive → robust is one measurement, the yaw rate. Without it heading is
observable only through GNSS, so losing the fix leaves the estimate rotating at
whatever rate it last held. Absolute IMU yaw is deliberately not fused: plenty
of vehicles carry a gyro and no usable magnetometer, and assuming a heading
reference would hide the problem rather than solve it.
radar removes one and adds one, and the pairing is the point: under slip
the encoders are confidently wrong, and an EKF blends by inverse variance, so a
lying measurement has to be removed rather than outvoted. See
design.
crosscheck keeps both, which works only because slip_monitor stops the
wheels claiming a precision they do not have: it rates them against the radar
and widens their covariance by how far the two disagree. On the same drive it
matches radar — 0.318 ± 0.139 m against 0.267 ± 0.185 m — so removing a sensor
was never the necessary part. It also publishes the slip ratio on slip, and
widens rather than narrows when nothing is watching the ground at all.
On O3DE the camera is the reference
The radar only exists in the headless simulator. O3DE has no ground-speed sensor of any kind, so there the job falls to the camera, the only other thing aboard that measures motion against the world rather than against a wheel.
ros2 launch kraken_camera visual_odometry.launch.py namespace:=kraken1 runs
three nodes: stereo_camera_info to make the simulator’s two cameras
describable as one pair, rtabmap_odom’s stereo_odometry to turn that pair
into motion, and visual_ground_speed to reduce motion to a forward speed. It
publishes ground_speed — the same topic the radar publishes — so nothing
downstream needs to know the difference: the fault channels, slip_monitor and
both the radar and crosscheck profiles are already wired to that name.
Two corrections stand between rtabmap’s odometry and being usable as that reference.
Its twist covariance is overconfident, which is the one thing this stack cannot tolerate. Measured against ground truth on a simulator verified to hold real time and hold exactly one robot, sigma runs from 0.62 to 1.16 of the error actually made, over nine runs. Sigma is multiplied by 2 — the worst of those rather than the average, because an average leaves the filter overconfident in whichever conditions turn out worse than average. Its pose covariance is the opposite, 11 to 19 times conservative; the two are filled independently and neither predicts the other.
The spread matters more than the middle. Repeating one case moves this figure further than changing the case does — straight running gave 0.90, 1.16 and 1.14 on three runs, while turning gave 0.79, 0.62 and 0.96 — so a factor fitted to a single run of each case is fitted mostly to noise. One was, and read 1.6 from a worst case of 0.64 that three runs later turned out to be 0.62.
Those figures belong to the 0.12 m baseline. The 0.5 m pair needed a factor of 3, so narrowing improved the honesty of the covariance about as much as it improved the accuracy of the estimate. Change the baseline and this has to be measured again.
Frames it cannot solve are dropped rather than scaled. rtabmap reports them
honestly, with covariance 9999 and a pose that has not moved. A zero speed
published during a turn is not a cautious reading but a wrong one, and
slip_monitor would read it as the wheels disagreeing with the ground by the
whole speed of the robot. Publishing nothing is what “I do not know” looks like
on a topic, and slip_monitor already widens the wheels when its reference goes
stale.
It works, turns included, on a 0.12 m baseline. Straight it follows the row to within 0.4% over 17 m; through turns of 80°, 111° and 137° it holds to under 0.7% and solves every frame of all five cases measured.
That is a recent correction rather than a standing property. On the 0.5 m baseline this stack shipped with, visual odometry did not degrade on turns, it stopped: past roughly 46° of heading it failed to solve between a third and two thirds of its frames and reported centimetres where the robot had moved metres. The cause was near range, not turn rate — the slowest turn measured is one of the failures and a faster one is perfect — and narrowing the pair fixed it. See simulation for the measurement.
The dropping still matters, because it is what stands between that regression
returning and slip_monitor reading a zero as the wheels disagreeing with the
ground by the whole speed of the robot.
The lateral constraint is in every profile. A differential-drive base cannot translate sideways, but nothing in the filter knew that, and the estimate happily ramped to 0.98 m/s of sideways motion on a robot whose true lateral velocity is zero. It is a kinematic constraint rather than a sensor, so it belongs in all three, and it stays true under slip because slip is forward.
The naive profile is kept, and asserted to stay broken, as the control case.
3. The result
One EKF configuration, one extra fused measurement, same 40-second drive with the GNSS fix killed at t=15 s. Ten runs per profile, seeds 0–9:
| profile | fuses | worst position error | worst heading error |
|---|---|---|---|
naive |
wheel speed + GNSS | 21.8 ± 0.9 m | 179.9 ± 0.04° |
robust |
+ IMU yaw rate | 0.36 ± 0.25 m | 1.9 ± 1.2° |
Both look identical while the fix is healthy. That is the entire point: the bug is invisible until the day it matters.
4. The robustness suite
Ten scenario files, each a YAML description of a drive and the faults injected during it. Adding a failure mode is adding a file; no Python required.
| file | fault | asserts |
|---|---|---|
total_gnss_dropout |
fix lost entirely | stays localised |
gnss_dropout_naive |
fix lost, no gyro fused | diverges (control case) |
gnss_degraded |
RTK → single point, 2 m noise | position bounded, heading recovers |
gnss_spoof |
fix walks off at 0.2 m/s, covariance still tight | follows the spoof (known gap) |
imu_dropout |
gyro dies, GNSS healthy | position bounded, heading lags then recovers |
wheel_slip |
encoders report 2× the real motion | stays localised |
terrain_dropout |
fix lost and the ground is slippery | drifts 9.6 m while heading stays under 3° |
terrain_dropout_radar |
the same, with the radar replacing wheel speed | recovers to 0.27 m |
terrain_dropout_crosscheck |
the same, with both fused and the wheels cross-checked | recovers to 0.32 m |
nav_baseline |
none, one Nav2 goal 12.65 m out | arrives, and is really there |
nav_terrain_dropout |
the same goal, fix lost, ground slippery | believes it arrived, is 5.7 m short |
nav_terrain_dropout_radar |
the same again, with a ground-speed radar | arrives late, and is really there |
Four assert failure on purpose. gnss_spoof documents a real gap — the filter
has no innovation gate, so a confident lie is a trusted lie. Better red and
visible than assumed and absent.
What each injector mode does to which channel is in fault modes. Thresholds live in the scenario files, each annotated with the distribution actually measured over a sweep.
A goal is only sent once the stack can take one. Waiting for the first score
message says localisation is up and says nothing about navigation, and the
nav_* scenarios cut GNSS milliseconds before the goal goes out: if
navsat_transform has not fixed a datum by then, map → odom never appears,
the global costmap times out waiting for it, and Nav2 rejects the goal outright
in about 16 ms. That reads as a navigation failure and is a race in the harness
— roughly one run in eight, and every run under load. The runner now waits on
the map → base_link transform itself before the first navigate_to, rather
than on a proxy for it.
5. How a run is scored
Four decisions do most of the work, and each is argued in design:
- Errors are relative to the pose pair captured at the fault instant. The map frame and the simulator’s world frame never line up exactly, and that constant offset is not a localisation error.
- Worst case and final value are bounded separately. Losing a sensor mid-turn produces a transient the filter recovers from; for some faults that is acceptable and a permanent offset is not.
- Two guards. A scenario must cover a minimum path length and a minimum amount of rotation. A robot wedged against a wall has beautiful localisation error, and without those checks the suite would call it a pass.
- Goal error is scored twice, believed and true. Nav2 judges arrival from the estimate, because the estimate is all it has, so a bias in the estimate moves the finish line with it. The failure is not that either number is bad, it is that they disagree.
-
The journey is scored, not just the endpoint.
cross_track_errormeasures ground truth against the first plan laid down for each goal — the first, because Nav2 replans from wherever the robot wandered to and the live plan would hide the wandering.recovery_countcounts the times the navigator fell back on backing up or waiting.elapsed_time_sis what a run cost. Without these a controller that crosses the aisle twice and one that holds the centre line score identically. - Every configured sensor is checked to be fused at all, in every scenario,
unconditionally.
robot_localizationdrops a measurement it cannot transform into the base frame and says so only atRF_DEBUG, so it can discard an entire input for a whole run while still publishing at its configured rate with a plausible covariance. That is not hypothetical: it happened, and therobustprofile silently behaved asnaivein nine runs out of ten. A watchdog now watches the inputs from outside the filter and the suite fails if any of them never arrives or never resolves, because a scenario that scores well against a filter which ignored a sensor has measured the wrong stack.
Do not quote a single run. The simulator steps on a wall-clock timer while
the filter, injector and scorer consume its output over DDS, so the interleaving
moves between runs. Use ros2 run kraken_scenarios sweep <scenario> -n 10 and
read the order of magnitude.
Ground truth has to be in the frame it claims
The headless simulator stamps ground truth in the world frame. O3DE does not: it
measures from wherever the robot was spawned, facing whatever way it was facing,
and stamps that <ns>/odom — which is also the name of the map-aligned frame the
filter publishes. Two different frames, one frame_id.
map->odom therefore carried the spawn translation but no rotation, and RViz
drew the truth arrow ninety degrees from the machine it described. Ground truth
itself was never wrong — driven straight, its course and its quaternion agree to
0.3° and its twist is properly body-framed — only the frame it was published in.
ground_truth_map republishes it in map, so O3DE agrees with the convention
the headless sim already followed. The translation comes from tf, because where
row one lands in map is decided by the georeference rather than by the level;
the rotation is a parameter, because the published one is the identity that
causes the problem.
Trap. An identity
map->odommeans “not yet”, not “the origin”. The filter publishes one until GNSS converges, and latching it pins ground truth to the map origin — 63 m from the robot, in a run that otherwise looks healthy.
Scenario scoring was unaffected throughout, because it compares reference-relative poses and a constant offset cancels. What was affected was every by-eye reading of RViz.
6. Where localisation and navigation meet
nav_terrain_dropout is the scenario that joins the two halves of this repo.
Every sensor is healthy and every message is honest — the wheels really are
turning at the commanded rate, the encoders really do report that, the ground
simply declines to convert it into motion. There is no disagreement for a filter
to detect.
| flat, healthy fix | slippery, no fix | slippery, radar | |
|---|---|---|---|
| distance to goal, believed | 0.583 m | 0.587 m | 0.77 m |
| distance to goal, true | 0.617 m | 5.658 m | 0.75 m |
| ground actually covered | 12.17 m | 7.13 m | 12.05 m |
| time to goal | 19.0 s | 21.6 s | 34.3 s |
| worst cross-track error | 0.58 m | 0.59 m | 0.40 m |
| recovery behaviours used | 0/8 | 0/8 | 0/8 |
| Nav2 reported success | 8/8 | 8/8 | 7/8 |
Eight seeds each. The absolute goal errors are set by the controller’s 0.6 m
xy_goal_tolerance — the goal checker halts the machine as soon as it is inside
that radius — so read the columns against each other rather than against zero.
The robot’s own account of the run is unchanged while it sits 5.7 m from where it was sent, and it reported SUCCEEDED in every run. Cross-track is flat across all three columns, which locates the failure: the slipping robot is not wandering, it drives the right line and stops short. The radar recovers it — and takes 34 s instead of 19, because the ground really is slippery and no sensor changes that. Arriving late is what success looks like here; arriving on time is the lie.
The radar column is 7/8 rather than 8/8 because roughly one seed in eight never leaves the start: the goal returns in under a second having covered 0.00 m. It reproduced across two independent sweeps and is listed in known gaps.
7. Stopping when the pose is not trustworthy
Everything above measures how wrong the filter gets. None of it stops the
machine, and a robot that keeps driving on a pose it has lost is the failure
that matters: nav_terrain_dropout above reports SUCCEEDED while sitting 5.7 m
from where it was sent.
safety_monitor sits in the command path rather than beside it. Nav2’s
controller and behaviour servers, and the scenario runner, all publish to
cmd_vel_raw; the monitor is what turns that into cmd_vel. So it cannot be
bypassed, and it cannot fail quietly — if the process dies nothing republishes
cmd_vel, and the machine times out to a stop. A monitor that only published an
opinion would go silent exactly when it mattered, and silence is
indistinguishable from health.
When a bound is breached it stops first and deactivates second: a zero
command goes out immediately and is repeated at 10 Hz, and only then is
controller_server asked for a lifecycle DEACTIVATE. The stop latches.
It judges three things, and two of them are worth less than they look.
What each check is worth
Are the filter’s inputs usable? This one is solid, and it is why the node
exists. robot_localization discards a measurement it cannot transform into the
base frame and reports that at RF_DEBUG only, so a filter can drop 100% of an
input all run while publishing at its configured rate with a plausible
covariance. That is not hypothetical — a transport bug kept
base_link->imu_link off /tf_static and silently demoted robust to naive
in nine runs out of ten.
Is the position covariance bounded? Only good for gross loss. Measured across the suite at seed 0, claimed 1-sigma against actual error:
| scenario | claims | actually is | |
|---|---|---|---|
nav_baseline |
0.077 m | 0.078 m | agrees |
wheel_slip |
0.080 m | 0.502 m | agrees |
gnss_spoof |
0.084 m | 8.090 m | blind |
gnss_degraded |
0.599 m | 1.708 m | agrees |
terrain_dropout_radar |
7.374 m | 0.241 m | false alarm |
total_gnss_dropout |
8.212 m | 0.173 m | false alarm |
terrain_dropout |
16.334 m | 9.628 m | agrees |
gnss_dropout_naive |
249.8 m | 23.215 m | agrees |
Two things follow. A spoof is invisible to it: a false fix makes the filter
more confident while taking it 8 m off course, so this check is no defence at
all against gnss_spoof — the crosscheck profile is. And there is no
threshold below about 9 m that separates a lost filter from a merely
pessimistic one, because terrain_dropout_radar claims 7.4 m while being 0.24 m
out. The bound therefore defaults to 12 m: a backstop against the filter
falling over, not a guarantee of staying in the row. Tightening it means
calibrating the covariance first, and process_noise_covariance is currently
unset, so robot_localization’s defaults apply.
Do the wheels still agree with the ground? Healthy runs measure a slip ratio of 0.16, a scenario built around slip measures 0.55, and a stationary machine reads about 1.0 because the ratio is a division by nearly nothing. The bound is 0.8, and the startup reading is what the settling tolerance is for.
Yaw covariance is not used. It reads about 15.7° at t=0 and 91° at t=50 in every run regardless of health, because no absolute heading is ever fused — it is a clock, not a health signal.
Why the suite mostly runs with it off
Every other scenario asserts a min_path_length: they exist to measure how far
localisation degrades while still driving, so a safety stop would fail the
very runs it is meant to protect. They pass max_position_stddev: 0.0, which
means no bound. The bringup default is the production value, because a safety
function that has to be switched on is one that will be found switched off.
safety_stop is the scenario that turns it on, and it is the only evidence the
stop works. Naive profile, GNSS lost entirely: the machine covers 15.5–16.3 m
over four runs against 32.1 m when left to drive on, and its worst position
error is 7.5–11.4 m instead of 23.3 m.
The settling tolerance and the stop heartbeat are both counted on the node clock, not the wall. The first version used wall timers, and under a simulator at 3x that made a ten-check tolerance thirty seconds long in the world the machine was driving through — the stop fired as the run ended, and the machine covered the full 32 m. The heartbeat has the same problem in slower motion: the simulator ages commands in sim nanoseconds, so a wall-clock heartbeat gets weaker against that timeout as the real time factor rises.
8. Known gaps
-
navsat_transformlogsLatitude 52.2297d more than 20d from N poleonce or twice at startup. It is upstream, it is harmless, and the obvious fix is worse than the symptom.robot_localization initialises
utm_zone_to 0 and passes it toGeographicLib::UTMUPS::Forwardas thesetzoneargument, where 0 means UPS — the polar projection. Withwait_for_datum: truethe zone is not filled in until the datum is computed, which waits for the firstodometry/filtered, so any GNSS fix arriving before that is asked for a polar projection of a latitude nowhere near a pole, and throws.Measured: 0–2 occurrences per run, all inside a 15–40 ms window before
Datum UTM coordinate is (34 north, ...).nav_baselineproduces zero, because atreal_time_factor: 1.0the filter publishes before GNSS arrives — which is what confirms it is a startup race rather than a misconfiguration. The dropped fixes cannot matter:transform_good_is still false, sonavsat_transformis not publishing anything yet.use_local_cartesian: truewould silence it, and that is the trap. There is no zone in that path so nothing throws — instead the same pre-datum fix is projected against an origin that has not beenResetyet, stored inlatest_cartesian_pose_, and can be published once as a real position. The UTM path throws andreturns, so it stores nothing. The loud error is the safe behaviour, and the quiet alternative publishes one bad sample.Fixing it properly means fixing it upstream; the zone should be
-1(STANDARD) until it is known. -
The filter over-rotates by about 10% on a sustained turn in O3DE, and it is the position updates doing it, not the gyro. Established by elimination:
the IMU integrated yaw 211.3 against truth 211.2, ratio 1.0007 the fault injector both streams 50 Hz, no duplicate stamps, same magnitudes the filter’s rate imu 0.1680, filter 0.1679, truth 0.1685 rad/s Comparing each source against its own rate integral is what localises it — truth’s pose runs 2.9° past its own rate (discretisation), the filter’s runs 17.9° past. Remove
odom1and that gap becomes +0.0°, with the error against truth falling from +10% to −1.8%.This is not a coding bug. Nothing here measures an absolute heading —
imu0_configfuses only yaw rate — so heading is observable only by comparing GNSS fixes against the direction the machine believes it is driving.It does not reproduce in the headless simulator.
turn_healthyexists to check that, and it is the only turning scenario that breaks nothing first:seed truth turns worst heading error error at the end 0 428.3° 3.76° −0.89° 1 428.0° 5.57° −1.78° 2 428.6° 5.48° −2.25° That is −0.2% to −0.5% of the turn, and the sign is the other way: it slightly under-rotates. So the mechanism is not the EKF’s GNSS coupling on its own, or it would appear here too.
What differs is slip. The headless simulator drives a kinematic bicycle, so the machine travels exactly where it points. A real vehicle in O3DE travels at a slip angle to its heading, and GNSS measures the direction of travel — so inferring heading from displacement pulls yaw toward the path.
That reproduces.
headless_simtakes aslip_gain, in radians of body slip per m/s² of lateral acceleration, andturn_slipisturn_healthywith it turned on. This turn runs at 0.8 m/s and 0.25 rad/s, so lateral acceleration is 0.2 m/s² and the slip angle is0.2 × slip_gain. Swept at seed 0:slip angle truth turns worst heading error at the end position error 0° 429.7° 3.77° 1.81° 0.187 m 5° 428.3° 7.17° 4.64° 0.121 m 10° 429.4° 13.74° 11.98° 0.175 m 20° 429.4° 20.47° 16.43° 0.311 m The heading error tracks the slip angle roughly one for one, while the truth rotation and the position error do not move: the fix is still good, it is the heading that goes. A 10% over-rotation on a 210° turn is 21° of extra heading, which this says needs a slip angle of about 20°.
So the mechanism is established and is now reproducible headless. But O3DE does not slip that far, so it is not the explanation. Measured on
main, fresh sim and fresh spawn each time, RTF 1.000, one publisher asserted, no steering saturation, driving straight and then cornering:straight turning (0.25) turning (0.10) slip from the reported twist +0.06° +16.21° +6.98° slip from the actual path +0.00° +0.59° +0.45° The machine travels within half a degree of where it points. A 10% over-rotation needs about 20°.
The twist column is a separate finding, and a real defect: it disagrees with the path, and it scales with yaw rate, which is the signature of a velocity reported about a point offset from the pose origin rather than of slip. Solving
v·tan β = ω·rgives r = 1.06 m, half the 2.2 m wheelbase — O3DE reportsground_truth/odom’s twist about the vehicle centre while its pose is at the rear axle. Anything reading that twist as a body velocity is reading a lateral term that is not there.That matters beyond the twist, because it shows this simulator does label quantities
base_linkthat are measured somewhere else.gnss/fixarrives withframe_id: kraken1/base_link, sonavsat_transformlooks up an identity offset and applies no antenna correction — and if the antenna were really forward of the rear axle, its course would lead the heading in a turn by the sameatan(ω·r/v).It is not. Comparing the two positions directly does not work, because
ground_truth/odomis in the odom frame — rotated 90° from ENU here — while the fix projects into ENU, so the residual drifts metres across a turn for reasons that have nothing to do with the antenna. Courses avoid it: an offsetrmakes the antenna’s track lead the body’s byr·(ω/v), so regressing the course difference onω/vgivesras the slope and leaves the frame rotation in the intercept. Overω/vfrom −0.009 to 0.240 rad/m:antenna offset (slope) −0.002 m frame rotation (intercept) −90.002° residual 0.009° The antenna is where it says it is, to 2 mm. A 2.2 m offset would have swung the course difference by about 30° across that range; the whole spread was 0.053°.
So both geometric explanations are now excluded by measurement — the machine does not slip and the antenna is not offset — while the EKF’s dependence on GNSS remains established, since removing
odom1still takes the gap to +0.0°.Two more candidates have since been excluded, both by making the headless simulator do the thing and watching nothing happen:
tried reasoning result wheel speed reading high GNSS pins the radius, so k·s = R·θforcesθ' = k·θno effect at k = 1.20 wrong initial heading nothing measures absolute yaw, so the error must converge out of GNSS, and that convergence is rotation no effect at 90° The first is wrong because
vxis a state the filter can correct from GNSS, so a wheel scale error is absorbed into velocity rather than forced into yaw. The second is wrong because the filter converges the heading during the 15 s straight settle, beforemark_reference— a 90° error gives 3.90° of worst heading error against 4.57° for no error at all, which is worth knowing in its own right: the filter self-corrects heading, given a straight run and GNSS.Three real defects turned up while excluding these, none of which is the cause:
ground_truth/odom’s twist is about the vehicle centre while its pose is at the rear axle (the 1.06 m above). Anything reading that twist as a body velocity reads a lateral term that is not there.- O3DE’s wheel odometry over-reads yaw rate by 16.4% while cornering
(median, p10 1.039, p90 1.327). It never reaches the filter —
odom0_configtakes vx and vy only, index 11 is false — but it is wrong. Wheel speed itself is honest: 1.0005 straight, 1.0286 cornering, and that second figure is a lower bound because the samples where the machine has ploughed to a crawl are too slow to take a ratio from. - The scenario harness does not pass
initial_yawwhileorchard.launch.pydoes, fromrow_heading_deg, which defaults to −90°. So every O3DE scenario runs the filter from 0° while the machine faces −90°. Harmless, per the measurement above, but the harness and the bringup should not disagree about the machine they are starting.
What is left is unexplained. The gap is reproducible on O3DE, absent headless, and provably not slip, not antenna geometry, not GNSS latency (a lag τ is indistinguishable from a longitudinal offset
−v·τ, and that measured −0.002 m, i.e. 3 ms), not wheel scale and not initial heading.Fusing visual odometry’s yaw was tried and did not measurably help: paired alternating runs gave 6.95% without it against 6.20% with, and the sign flips on one of three pairs. The baseline itself moved from 8.7–11.6% to 5.26–8.40% between sessions with no configuration change, so between-session drift (~3 points) dwarfs the effect (<1 point). Any A/B on this number must be paired, alternating, and within one session — and n=3 is still not enough.
- Innovation gating is available but switched off, so
gnss_spoofis followed rather than rejected.robot_localizationsupports Mahalanobis rejection thresholds —twistN_rejection_threshold,odomN_pose_rejection_thresholdand friends — and every one of them defaults toDBL_MAX. Turning them on is unlikely to close this particular gap, because a gate catches jumps and the spoof is a 0.2 m/s ramp whose per-update innovation stays small. nav_terrain_dropout_radarfails to start in roughly one run in eight. The goal comes back in under a second withpath_length0.00, leaving the robot the full 12.65 m from the goal. Reproduced in two independent 8-seed sweeps; unrelated to the terrain, which the other seeds cross without trouble.
The full list, with the reasoning behind each, is in design.