Design notes
Why a shim instead of simulator support
Fault injection lives in a node between the sensors and the filter, not inside the simulator. That decision buys three things:
- The same scenarios run against O3DE, against the headless model, or against a recorded bag, because none of them need to know about it.
- Faults are testable without a GPU, so the suite runs on any machine.
- The filter under test is the real one, wired the real way. Nothing is stubbed.
The cost is that faults are applied to messages, not to physics. wheel_slip
corrupts the encoder reading; it does not actually make the tyres lose grip. To
a filter fed only that message the two look much alike, which is why the shim is
enough for most of this suite. Terrain is the deliberate exception, and it lives
in the simulator for the reason given below.
Why a headless simulator at all
O3DE is the better demo and a worse test harness: it needs a GPU, an authored level, and a long build. The suite would then run rarely, and a robustness suite that runs rarely is decoration.
kraken_sim is a unicycle model with about a hundred lines of physics in it.
It owns /clock, so scenarios run at a configurable multiple of wall speed and
produce the same numbers on every machine.
The time factor is load-bearing. At 5× the EKF could not keep up with its
own input, and the same scenario returned 0.46 m one run and 1.46 m the next.
At 3× it keeps up, but nothing gates a simulator step on the consumers having
finished the previous one, so the interleaving still moves between runs. Holding
the seed fixed and repeating total_gnss_dropout eight times gives 0.136–0.405 m
(mean 0.183, sd 0.092). The values are quantised rather than noisy — runs repeat
exactly — so this is piecewise determinism across scheduling interleavings.
Varying the seed is the larger effect, because after GNSS loss the robot is dead
reckoning and each seed is a different random walk. Bounds are therefore set from
sweep distributions rather than single runs, and each scenario file records the
range observed. If you raise real_time_factor, re-measure.
Why errors are relative to the fault instant
navsat_transform anchors the map frame on a datum; the simulator anchors its
world frame on its own origin. Those never line up perfectly, and the residual
offset is a constant, not a localisation failure. Comparing the estimate and
the ground truth in the frame of the pose pair captured when the fault was
injected cancels it, leaving only the drift accumulated since.
Why path length and rotation are checked
A robot that never moved has excellent localisation error. So does one wedged
against a wall, or one whose cmd_vel never arrived. Every scenario therefore
asserts a minimum ground-truth path length and a minimum accumulated rotation,
so that a run has to have actually happened before its error numbers are
allowed to mean anything.
Both are path totals rather than net displacement, because the trajectory loops back on itself and net displacement would be near zero.
Why worst-case and final error
Losing a sensor mid-turn produces a transient the filter then recovers from. For some faults that transient is acceptable and a permanent offset is not, so scenarios choose:
max_position_error/max_heading_error_degbound the running worst casemax_final_position_error/max_final_heading_error_degbound the settled value
imu_dropout is the clearest example: heading lags by ~30° during the turn and
comes back under 15°, while position never exceeds half a metre. Bounding the
peak there would just be a slow way of asserting that turning exists.
Why lateral velocity is fused
A differential-drive base cannot translate sideways, but nothing in the filter
knew that. odom0_config fused forward speed only and imu0_config yaw rate
only, so once GNSS stopped, lateral velocity was unobservable. Measured, the
estimate ramped to 0.98 m/s of sideways motion on a robot whose true lateral
velocity is exactly zero, and integrated it straight into cross-track error.
Heading error stayed under 2° throughout, which is why every metric kept reporting a healthy filter. Position error that grows while heading error does not is a velocity-state problem, not a tuning problem.
The simulator now publishes that zero with a covariance and both profiles fuse it. It is a kinematic constraint rather than a sensor, so it belongs in both, and the two configs still differ by exactly one measurement. It moved the robust profile from 2.57 m mean (max 5.13) to 0.359 m (max 0.904) across seeds. Most of what earlier notes here blamed on scheduling jitter was this.
Why the naive profile is kept
ekf_naive.yaml and ekf_robust.yaml differ by one fused measurement, the IMU
yaw rate. Without it, heading is only observable through GNSS, so losing the fix
leaves the estimate rotating at whatever yaw rate it last held.
Keeping the broken configuration, and asserting that it stays broken, is what
makes the comparison a measurement rather than a claim. If someone changes the
stack so the naive profile stops diverging, gnss_dropout_naive goes red and
tells them the demo no longer demonstrates anything.
Why Nav2 runs without a map
kraken_nav wires Smac Hybrid-A* to a curvature-following controller on top of
the same filter output the scenarios measure. Obstacles come from one source:
the lidar the O3DE Kraken carries, read by an ObstacleLayer on both costmaps.
The headless kinematic model has no ranging sensor, so against it that layer
never receives an observation, the costmaps stay empty and what the scenarios
exercise is the interface and the control loop rather than obstacle avoidance.
There is no map server and no static layer either way. Both costmaps are
rolling windows, so the world is remembered only as far back as the window
reaches, and the only prior the machine is given is the geofence — a costmap
filter rather than a layer, because it is a boundary set for it and not
something it senses. Structure inside a row is measured rather than mapped:
kraken_orchard fits a line to the trunks either side out of the same cloud
and publishes the corridor between them, which needs no estimate of where the
robot is in the world.
It is wired to the flat headless model first, so the plumbing is verified
independently of terrain. minimum_turning_radius is a smoothness choice rather
than a kinematic limit — a unicycle can spin in place — and only becomes a real
constraint once the trailer is modelled, which is why the planner is Hybrid-A*
and not Navfn.
Why terrain is in the simulator, not the shim
Every other fault here is a lie told to the filter. Terrain is not: the wheels turn at the commanded rate, the encoders report that honestly, and the ground declines to convert it into motion. No shim between the sensors and the filter can produce that, because the robot’s true position has to change.
It is also the only fault in the suite with no cross-check available. A spoofed fix disagrees with the wheels. A dead gyro stops saying anything. Slip produces a consistent, plausible and entirely honest set of messages that happen to describe a robot which is somewhere else.
terrain_dropout measures the consequence against an otherwise identical flat
run: 9.57 m of position error instead of 0.36 m, with heading error inside the
same bound, because heading comes from the gyro and the gyro is not wrong. The
spread across seeds falls from sd 0.25 to sd 0.022 — flat dead reckoning is a
random walk over the noise draws, slip is a bias.
The model is traction only: a scalar in (0, 1] per rectangular patch, scaling forward and yaw rate together. There is no slope, so there is no downhill drift and the nonholonomic constraint the filter now fuses stays true. Adding height would break that constraint, which is the more interesting experiment and the reason traction lives in its own module rather than three lines in the step function.
Why the goal error is scored twice
The scorer reports goal_error_estimated and goal_error_true separately. The
first is the distance from the goal to where the filter thinks the robot is; the
second is the distance to where it actually is. On good ground the two agree and
the pair looks redundant. That is the point of measuring both.
Nav2 decides it has arrived by comparing the goal against the transform tree, which is fed by the filter. It has no other source of truth, so a goal check can only ever confirm that the estimate arrived. Anything that biases the estimate biases the arrival test by exactly the same amount, and the error cancels out of every number the robot can compute about itself.
Terrain slip is such a bias. The wheels turn at the commanded rate and the
encoders report that rate honestly, so with no fix to correct it the estimate
advances at the commanded speed while the robot advances at 45% of it. Over the
12.65 m goal used by nav_baseline and nav_terrain_dropout, across 8 seeds:
| flat, healthy fix | slippery, no fix | slippery, radar | |
|---|---|---|---|
goal_error_estimated |
0.583 +/- 0.029 | 0.587 +/- 0.010 | 0.77 +/- 0.07 |
goal_error_true |
0.617 +/- 0.029 | 5.658 +/- 0.026 | 0.75 +/- 0.08 |
path_length |
12.17 | 7.13 | 12.05 |
elapsed_time_s |
19.0 | 21.6 | 34.3 |
worst_cross_track_error |
0.58 +/- 0.15 | 0.59 +/- 0.23 | 0.40 +/- 0.13 |
recovery_count |
0/8 | 0/8 | 0/8 |
| Nav2 reported success | 8/8 | 8/8 | 7/8 |
These are the headless kinematic sim, which scales commanded velocity by a traction factor. O3DE gives real friction and load transfer instead, so the mechanism carries over but these magnitudes will not.
The absolute goal errors are set by the controller’s 0.6 m xy_goal_tolerance,
not by the filter. The goal checker stops the machine as soon as it is inside
that radius, so ~0.6 m is the floor for every column and no bound tighter than
the tolerance can be met. Read the columns against each other, not against zero.
Take the middle column first. The believed error is the same as the control’s to within a hundredth of a metre. The true error is 9x worse. Every signal available to the robot – the goal check, the filter covariance, the encoders, the heading, which stays under 1 degree – says the run went as well as the control run did. This is the second failure in this project that is invisible in heading and visible only in position; the first was the unobservable lateral velocity above, and both were found by scoring against ground truth rather than against the estimate.
The cross-track row is what says where the failure is. It is flat across all
three columns, so the slipping robot is not wandering: it drives the line it was
given and simply does not get far enough along it. The failure is entirely
along-track, which is why goal_error_true catches it and nothing lateral does.
The spread is the other half of it. goal_error_true varies by 0.026 m across
seeds, less than the flat case’s 0.029 m, so the miss is not noise that a
longer run would average out. It is a systematic bias, reproducible to the
centimetre, which is what makes it dangerous rather than merely inaccurate.
Nav2 is not wrong to report success, and it is worth being precise about that. It was asked to drive the estimate to a pose and it did. The failure is that nothing in the stack is positioned to notice the estimate and the robot have parted company, because the only instrument that could notice is the fix, and the scenario removed it.
nav_terrain_dropout asserts on navigation_succeeded, which it did not always
do. Nav2 used to return SUCCEEDED in only 5 of 8 runs, thrashing near the goal
until the progress checker fired, and asserting either outcome would have made
the test flaky. That variation turned out to be the controller being told it
was a differential drive: given the robot’s real kinematics it settles, and now
declares success in all 8 runs while 5.5 m from the goal. The finding survived
the fix and got sharper, because a controller that reliably believes it arrived
is worse news than one that sometimes gives up.
Why arriving is not the whole score
Goal error is an endpoint measurement, and an endpoint measurement says nothing about the journey. A controller that crosses the aisle twice on the way and a controller that holds the centre line produce the same number, and in an orchard those are not the same run: the aisle is 3.5 m between trunk lines and the canopy closes it to about 3 m, so lateral excursions are what hit trees. Three metrics cover the gap.
Cross-track error is the distance from ground truth to the path the planner laid down. The subtlety is which path. Nav2 replans continuously, and it plans from wherever the robot currently is, so the live plan is drawn through any wandering and measuring against it always reads near zero. The scorer therefore latches the first plan published for each goal and holds it, re-latching only when the plan’s endpoint moves — a new goal, not a replan of the current one.
Measuring truth against a plan expressed in the map frame conflates two things:
how well the controller tracked, and how far the estimate had drifted. That is
deliberate and it is the same choice goal_error_true makes. The question this
metric answers is whether the machine physically went where the route said, not
whether it thought it did.
Recovery count and time come from the behaviour server’s action status
topics — backup, drive_on_heading, wait. Each invocation is the navigator
admitting it is stuck, which is a cost that does not show up anywhere in goal
error: a run that reaches the goal after backing up four times is a different
result from one that drove straight through. Costmap clearing is a service call
and leaves no trace on any topic, so it is not counted; the number is a floor
on how much recovering happened, not a total.
Elapsed time is measured from the reference mark rather than from the start
of the navigation goal, so open-loop scenarios get a duration too. Its most
useful form is a lower bound: nav_terrain_dropout_radar asserts that the run
takes at least 25 s, because a run that crosses slippery ground as fast as the
baseline crossed flat ground did not actually encounter the terrain, and no goal
error would notice that the scenario had stopped testing anything.
Upper time bounds are kept loose on purpose. These scenarios run at
real_time_factor: 1.0, so the controller gets whatever compute is left over,
and the same scenario measured 20 s on an idle machine and 69 s with the other
nine running beside it. The bounds guard the phase timeout; the sweep tool,
which runs one scenario at a time, is where durations are compared.
Why there is a ground-speed radar
The third column is what fixes it. A Doppler ground-speed radar bounces a beam off the ground and reads the returned shift, so it measures how fast the vehicle is travelling over the surface rather than how fast its wheels are turning. It is not exotic: agricultural machinery has carried these for decades, precisely because wheel slip in a field is the normal case rather than the fault case.
It is the only sensor in the stack that observes the quantity the bias lives in. GNSS would also catch it, but the scenario has removed GNSS, and that is the realistic case under canopy or in a headland shadow. The gyro sees rotation and is already correct. The encoders are, by construction, the thing being checked.
With it, true error falls from 5.494 m to 0.297 m and, more to the point, belief and truth agree again: 0.357 against 0.297, a gap of 0.06 m where before it was 5.26 m. The robot’s account of itself can be believed, which is a different and stronger property than the account being accurate. The true error also lands on the control’s 0.292 m, so the radar does not merely improve the slippery case, it recovers the flat one.
The run takes 64 s where the control takes 23 s. That is the honest outcome: the ground really is slippery and no sensor changes that, so covering 12.6 m takes longer. Arriving late is what success looks like here. Arriving in 23 s is what the version without the radar reports, and it is a lie.
An earlier version of this document claimed the radar run was the most reproducible of the three, and that fixing the estimate had stabilised the controller without touching it. Re-measuring after the controller was given the robot’s real kinematics reversed that: the unaided slippery case is now the tightest of the three, at 0.26 s and 0.05 m. The thrashing was never the estimate’s doing. It was the controller of the day, MPPI, planning for a robot that could pivot.
Why the radar replaces the wheels rather than joining them
The obvious change is to fuse the radar alongside the existing wheel velocity. It does not work, and the reason is worth stating because it generalises.
An EKF blends disagreeing measurements by inverse variance, so the sensor claiming to be more precise wins. The wheels report 0.01 m/s of noise and the radar 0.02, which weights the blend 4:1 towards the wheels and recovers about a fifth of a 0.44 m/s disagreement. Tightening the radar to 0.005 m/s still only reaches 0.45 m/s against a true 0.36.
The problem is not that the radar is too quiet. A covariance is a claim about
accuracy, and under slip the encoders’ claim is false: they report 0.01 m/s of
noise while being 0.44 m/s wrong. No quantity of correct evidence outvotes a
confident lie, because confidence is exactly what the filter weighs by. So
ekf_radar.yaml removes wheel forward speed rather than supplementing it. The
wheels keep the nonholonomic constraint, which stays true under slip, because
slip is forward and the constraint is sideways.
This is the one profile that is not a single measurement from its neighbour. It removes one and adds one, and the pairing is the point.
Why removing it turned out not to be necessary
The argument above tries tightening the honest sensor, finds it cannot rescue the blend, and concludes the lying measurement has to go. Only the first half of that follows. The other direction was never tried, and the same arithmetic that lets the smallest sigma dominate lets it stop dominating — with the wheels at 0.5 m/s against the radar’s 0.02:
\[\frac{0.8/0.5^2 + 0.36/0.02^2}{1/0.5^2 + 1/0.02^2} = 0.361\]against a true 0.36. Inflating the liar works where tightening the honest one does not.
Which leaves the question of where a 0.5 m/s comes from, since an encoder cannot
know it is slipping. It comes from the only local evidence there is: how far the
wheels disagree with something that watches the ground. slip_monitor measures
that, republishes wheel odometry with a covariance set by it, and publishes the
disagreement as a slip ratio — a number a fruit picker could act on, and one
this document previously listed as computed nowhere.
Same drive, same terrain, three profiles, eight seeds each:
| profile | speed comes from | worst position error |
|---|---|---|
robust |
wheels only, claiming 0.01 m/s while 0.44 m/s wrong | 9.566 ± 0.036 m |
radar |
wheels removed, radar instead | 0.267 ± 0.185 m |
crosscheck |
both, wheels widened by their disagreement | 0.318 ± 0.139 m |
The last two are the same answer to within their spread. A sensor does not have to be removed for lying; it has to stop lying. That generalises where deletion does not: a third opinion on ground speed joins by being cross-checked like the others, rather than by a decision about which of the existing two to throw away. That is what the camera does on O3DE, where there is no radar to remove anything in favour of — see localisation.
It also removes the case for innovation gating here. A gate asks only which measurement disagrees with the filter’s current belief, and that belief is dragged by whichever sensor is loudest — under slip, the 50 Hz wheels insisting they are precise. It would reject the honest sensor. Fix the covariances and the filter needs no referee.
What it costs. The cross-check is only as good as its reference, and that is now a stated assumption rather than an accident: comparing wheels against radar and widening the wheels encodes a decision that the radar is right. It measures speed over the ground directly rather than inferring it, which is the justification, but it is a judgement and not a proof. And when the reference fails there is no evidence either way — so the wheels widen rather than narrow, and hold the last disagreement they were shown rather than forgetting it. The ground does not start gripping because a sensor died.
Why a sensor is checked to be fused at all
The filter has no obligation to tell you it is ignoring something.
robot_localization drops a measurement it cannot transform into the base
frame, and reports that at RF_DEBUG — invisible at any normal log level. The
filter goes on publishing at its configured rate, with a covariance that looks
reasonable, and the only symptom is that one state quietly stops being
observable.
That is what a transport bug did here: base_link → imu_link never reached the
EKF, every angular-velocity measurement was discarded, and because
ekf_robust.yaml takes yaw rate from the IMU alone, heading stopped being
observable. robust produced naive’s numbers — 19.27 ± 6.43 m against
0.39 ± 0.24 m — and failed its own bound in nine runs out of ten. Nothing in the
logs said why. It was found by attaching a debugger-grade trace to the filter
and counting: 6717 measurements enqueued from the wheels, 6717 refused from the
IMU.
The lesson is not about that bug, which is fixed. It is that the suite could not tell the difference between a filter that fused a sensor and one that threw it away, and neither could any amount of reading the numbers: they were plausible, they were stable, and they were describing a different stack than the one configured. Every scenario now asserts that each configured input both arrives and resolves. It is the cheapest possible check and it is unconditional, because there is no scenario in which discarding a configured sensor is the intended behaviour.
Why there are two filters
Until recently there was one, and the odom → base_link transform came straight
out of the fault injector — raw wheel odometry, faults and all, with no gyro in
it. Everything working in the odom frame inherited that, Nav2’s local costmap
above all: the planner was avoiding obstacles positioned by dead reckoning.
The split is the arrangement robot_localization is designed for. The local
filter fuses everything except GNSS, so the odom frame stays continuous and
never jumps; the global filter adds GNSS and absorbs the jumps into
map → odom, which is where REP-105 says they belong.
Measured across five scenarios at eight seeds each, before and after, it changes
nothing it should not — nav_terrain_dropout, the closed-loop case that leans
hardest on the odom frame, agrees to 8 mm (5.6567 ± 0.0219 m against
5.6492 ± 0.0212 m). That is the point: it is a correctness fix for the frame,
not a tuning change, and a tuning change is exactly what it must not look like.
It is also where a visual odometry estimate goes. VO drifts, so it cannot be fused as an absolute pose against GNSS; the odom frame is the one place a drifting, continuous estimate is the correct thing to have. What is fused today is narrower than that — forward speed only, because the wheels lie about $v_x$ and nothing else — but the frame is the reason it can be fused at all.
Why the navigation scenarios run in real time
The localisation scenarios run at real_time_factor: 3.0. The navigation ones
run at 1.0, and the difference is not a preference.
Nav2’s controller runs in wall time while the simulator advances sim time, so
running the clock three times fast gives it a third of the compute per simulated
second. Open-loop scenarios do not notice, because nothing in them reacts to the
result. A closed loop does. It was measured under MPPI, which optimises by
sampling rollouts and so felt it hardest — on flat ground it survived, because
tracking a plan that works needs little search; under slip, where every rollout
mispredicts, it did not, and the scenario became a measurement of the host’s
spare CPU. The controller has since been replaced by ArcTracker, which is
cheap, but the clock stays at 1.0 for closed-loop runs: the coupling is the
point, not the particular controller.
This was found by accident. Adding the radar added two publishers, which was
enough extra load to change nav_terrain_dropout from a tight 5.32 +/- 0.13 m
to 3.95 +/- 2.29 m. The sensor could not have caused that, since the profile
under test does not fuse it; the load could, and did. In real time the scenario
returns to 5.33 +/- 0.08 m. The lesson is that a fast clock is free only for
open-loop measurements.
The radar’s noise is also drawn from its own RNG stream, seeded separately, so
that adding a sensor leaves every other sensor’s noise sequence untouched and
the existing scenarios’ measured thresholds still mean what they meant. That
held: total_gnss_dropout reproduces bit-for-bit, with a path-length spread of
exactly zero across seeds.
Known gaps
- Innovation gating is available but switched off, so
gnss_spoofis followed rather than rejected. The thresholds exist (twistN_rejection_thresholdand friends, in n-sigmas) and default toDBL_MAX. - Sensor faults are message-level. Terrain is the one physical fault, and it is traction only: no slope, no load transfer, no lateral drift.
Project/builds and the Editor runs on the GPU, but no level is authored, so nothing yet drives the ROS 2 Gem from the O3DE side.- The navigation scenarios now score the path as well as the endpoint — cross-track error, recovery count and elapsed time — but cross-track is measured against the planner’s own route, so it says whether the machine drove the line it was given and not whether that line was a good one. Nothing scores clearance to the trunk rows.
nav_terrain_dropout_radarfails to start in roughly one run in eight: the goal returns in under a second withpath_length0.00. Reproduced across two independent 8-seed sweeps, and unrelated to the terrain, which the other seeds cross without trouble.min_path_lengthfails such a run, so the suite is intermittently red here.- The radar corrects the bias but nothing reports it. Wheel speed minus radar speed is the slip ratio, which is a number a fruit picker could act on and is not published anywhere.
- Nothing detects the bias when the radar is absent, which is still the case for
every profile but
radar. The stack has no cross-check that would fire.