ADR-0005: Chi-square innovation gating with per-channel fault isolation¶
- Status: accepted
- Date: 2026-09
Context¶
The filter already gated updates. EskfConfig.gate_sigma rejects an update
whose innovation exceeds gate_sigma standard deviations, per component.
That gate has two defects, and neither is a threshold value.
It has no false-alarm rate. The threshold is 3 by hand, chosen before anyone asked how often a healthy receiver would trip it. There is no answer, so there is nothing to tune against and no way to trade detection against nuisance alarms deliberately. It is a number, not a decision rule.
It is stateless. A rejection is a counter increment. A receiver that has been lost for ten seconds and one that dropped a single frame produce the same record: one more rejected update. The system that is supposed to notice a contested sensor cannot tell a momentary spike from a dead channel, and so cannot respond differently to them.
ADR-0001 and ADR-0003 established that this project's failures are not smooth: a collapsed covariance, a correlated error the model cannot represent. Smoothing is what the filter already does well. What it lacks is a way to say this channel is lying rather than this number looks odd.
Decision¶
- Add
fdir/, with no SciPy. A tabulated chi-square threshold for the dof a navigation filter actually produces (1, 2, 3, 6), exact quantiles fromeval/statistics.pyfor the small untabulated values where Wilson-Hilferty is not trustworthy, and Wilson-Hilferty in closed form beyond that. - Test every update before the Kalman gain is formed. A rejected update
returns immediately:
P^+ = P^-, state untouched. - Track fault state per channel, with
max_consecutive_rejectionsto exclude a channel andauto_recovery_countconsecutive accepted updates to bring it back. Log transitions, not transients. - Gate the two blocks of a relative-pose fix as
vision_rotandvision_trans. They come from one transform, so a singlevisionchannel would let the healthy half vouch for the broken half. - Keep
gate_sigma. It is a second, independent bound, and the existing rejection counters (which is how the ADR-0001 defect was found) keep meaning what they meant.
Why alpha = 0.001 and not 0.01¶
The brief asked for 0.99 confidence and zero false rejections at that operating point. Those two are the same number, and it is not achievable: a gate at 99% confidence rejects 1% of healthy updates by definition, because that is what the 1% means.
Measured on this project's own fixture: 20 s runs, 12 visual seeds, 1212 healthy GNSS fixes:
alpha |
Healthy fixes rejected | Broken configuration |
|---|---|---|
| 0.01 | 13 of 1212 (1.07%) | 68 of 101 rejected, fault declared |
| 0.001 | 0 of 1212 | broken detection unchanged |
Nuisance rate matches the nominal rate almost exactly, which is the expected result and a good sign that the test is sound. But a healthy receiver tripping the fault detector once per hundred epochs is a receiver nobody can fly with, and it would drown the covariance-collapse alarm this subsystem exists to extend.
FdirConfig still accepts 0.99. It is a real operating point for a channel
whose every rejection is logged and acted on. It is not the default for a
channel sharing a filter with a health monitor.
Consequences¶
src/navkit/fdir/gating.py87.0% covered,fdir_manager.py90.2%.- GNSS: a 15 m spoof step and a 50 m multipath spike are both rejected;
baseline peak position error on the fixture is 0.29 m, so following the
fix would show up as 15 m. A 30-fix spoof escalates to
SENSOR_FAULTat the fifth consecutive rejection, not the thirtieth. - Vision: a 10 m translation step faults
vision_transwith zero rejections recorded onvision_rot. A 90 deg rotation step is the mirror image. The modelled anchor absorbs a 20 deg step, which is what the anchor is for. - The flagship benchmark case got worse, and this is the honest cost.
outage_visualATE 3.428 m -> 5.059 m;outage_visual_degraded_camera2.545 m -> 3.339 m. In both, FDIR rejects the GNSS fixes that return att = 20 safter the denial (51 of 75 inoutage_visual) because the filter ends the outage displaced and holding a collapsed covariance, and a gate that trusts that covariance reads a healthy 3 m fix as an outlier. The filter then dead-reckons the last 9 s instead of snapping back.
The gate is doing exactly what it was specified to do. The specification
assumes a calibrated S, and this filter does not have one in precisely
the configurations this project exists to study. The mitigation is a
consistency monitor (an innovation-consistency statistic that detects the
miscalibration and widens the gate when the chi-square premise is void)
not a different threshold. Tracked as blocker B4 and pinned by
test_fdir_throws_away_the_absolute_fixes_that_would_rescue_a_displaced_filter,
so the cost cannot be quietly forgotten or quietly re-measured as a
regression in the gate.
- A 20 deg rotation step is absorbed by the modelled anchor rather than
rejected. This is the anchor working, not the gate failing, and it is
stated here so a future test does not pick that magnitude and conclude
the gate is blind.
- SENSOR_CHANNELS lists four channel names, not three. There is no vision
channel, and there is no altimeter update in the filter; the constant
documents the vocabulary the tracker is called with, and
tests/test_estimators.py is where the actual call sites live.