Skip to content

Fault detection

Fault detection, isolation and recovery for measurement channels.

The estimator is the only thing that can tell a corrupted measurement from an unlikely one, because only it knows how much uncertainty it has already accumulated. This package provides that judgement in two layers:

navkit.fdir.gating The statistics. Squared Mahalanobis distance of an innovation, against a chi-square threshold with a stated false-alarm rate. Tabulated quantiles for the degrees of freedom a navigation filter produces, Wilson-Hilferty elsewhere, no SciPy. navkit.fdir.nis_monitor The distinction. A bounded per-channel history of recent gate outcomes, and the rule that separates an isolated impulse -- which gets no relief -- from a sustained run of failures, which is evidence that the filter is the thing that is wrong. See ADR-0006. navkit.fdir.fdir_manager The policy. Per-channel state that turns a stream of accept/reject verdicts into isolation, recovery, and bounded covariance inflation, so a single bad epoch cannot remove a good sensor and a dead sensor is not permanent. navkit.fdir.config, navkit.fdir.records and navkit.fdir.status What the policy reads and produces: the configuration, the per-update decision, event and channel-state records, and the status codes. They were part of fdir_manager and are still importable from it.

Wiring into the 21-state ESKF is described in ADR-0005, ADR-0006 and docs/architecture.md.

Per-channel counters. Public because the estimator reports them.

Fault-detection policy. All thresholds are stated, none are implicit.

Significance level, the upper-tail probability of a false rejection.

The persistence monitor's configuration, derived from these fields.

Derived rather than nested so that :meth:as_dict stays flat and one number in one place governs both the estimator's configuration and the monitor built from it.

A state transition worth logging rather than merely counting.

Chi-square gate, per-channel isolation, and adaptive re-acquisition.

One manager serves every channel and holds their states independently: a faulted GNSS receiver says nothing about the camera. The state is not thread safe, and does not need to be -- a filter's update path is single-threaded by construction here.

Two entry points, and the difference between them is the whole design: :meth:check is the plain gate, and :meth:evaluate_and_adapt adds the second pass that relieves a channel the filter itself has dislocated. Callers that cannot supply a covariance to inflate use check; the estimator uses evaluate_and_adapt.

State for sensor, created on first use.

Channels are created lazily rather than from a fixed enum so that adding a measurement model does not require editing the gate.

True while this channel is in an ADR-0007 spoof cooldown.

Separate from :meth:is_faulted because the two expire on different clocks: a fault clears on auto_recovery_count clean updates, a lockout only on elapsed time. A caller reporting "is this sensor usable" needs both.

Rolling mean innovation distance for sensor.

Exposed on the manager as well as the monitor because the diagnostics tool prints it per channel and a caller should not have to know the monitor is reachable.

Flat counters for the estimator's result dictionary.

Drop one channel's isolation state and its NIS history.

Both, and that is the point: re-enabling a sensor that the filter is no longer in the same place as would otherwise inherit the run of failures that disqualified it, and could be granted inflation on the strength of evidence gathered under a different geometry.

Gate one measurement and update the channel's isolation state.

Order of operations is deliberate. The innovation is gated first, even for an already-faulted channel: a faulted channel whose measurements are suddenly consistent again must be able to recover, and recovery is decided on how many good updates have arrived. Skipping the gate for a faulted channel would make recovery impossible, because every update would be excluded and nothing would ever be counted as good.

No covariance inflation happens here. Callers that have a covariance to offer should use :meth:evaluate_and_adapt; this method exists for the case where they do not, and for tests that want the bare gate.

Gate, and if the failure looks like the filter's own fault, re-gate.

This is the fix for blocker B5, and the reasoning behind both branches is the argument in :mod:navkit.fdir.nis_monitor.

A rejection is re-tested only when three things hold. The channel must have failed reacq_consecutive_rejections times running -- one bad measurement is not evidence of anything. The caller must have supplied P and H, because inflating a covariance you cannot see is not possible. And the re-gate must pass under the inflated covariance, not approximately.

The inflated innovation covariance is formed as S + H dP H^T rather than rebuilt from P and R. That is not an optimisation: it is exactly the value the estimator will have once it honours the decision, since S = H P H^T + R and the inflation adds to P only. Deriving it the other way would require the caller to also pass R and would create two chances for the re-gate and the fusion to disagree.

block names the state indices the measurement actually constrains. Inflating the whole 21x21 state on the strength of a position innovation would be both wrong and easy to get away with, because the result is still a valid covariance -- it would just be a filter that has suddenly become unsure of its own gyro bias.

The caller owns the units of the block as much as its identity. The variance computed here is derived from a translational drift rate in metres per second, so it is only meaningful for a translational block; the estimator restricts it to GNSS for that reason and for want of evidence on the others, and an empty block disables the second pass entirely rather than guessing.

Outcome of one innovation test.

accepted is the only field the estimator acts on. status carries the reason, because a filter that silently drops 40% of its GNSS fixes is indistinguishable from one that has degraded gracefully until you read a counter.

inflation_variance is the one field that asks the estimator to do something. When it is non-zero the estimator must add it to the diagonal of the state block named in inflation_block before forming the Kalman gain, and must recompute S from the inflated covariance -- not merely reuse the value the gate already saw. The gate's own re-gate in :meth:FdirManager.evaluate_and_adapt is computed from exactly that modification, so applying it as stated reproduces the distance the verdict was based on. Ignoring this field and fusing anyway would be a smaller change to the filter than honouring it, which is exactly why it is spelled out here.

Tunables for the persistence monitor.

The defaults are chosen for measurement streams arriving at 5--20 Hz on a platform that has just lost one of its sensors, which is the case this exists for.

Per-channel rolling record of gate outcomes, and the divergence call.

One monitor is shared by every channel, keyed by channel name, because the two visual blocks are gated separately and a channel that has been rejected three times running says nothing about its neighbour.

Record one gate outcome for channel.

accepted is the verdict the gate already reached; this monitor does not re-judge it. Recording the distance as well as the verdict is what lets :meth:mean_nis answer "was this channel nominal before?", which is the comparison that separates an isolated impulse from a channel that has been off for a while.

True if the last K outcomes for channel were all rejections.

Reads only the trailing run, so an old burst of failures cannot keep a channel eligible for inflation indefinitely: the moment one update is accepted the count restarts.

Mean Mahalanobis distance over the retained window, or 0.0 if empty.

Reported rather than used in the divergence decision. Under a correct model this sits near the degrees of freedom, so a value far above them is a useful summary statistic for a trajectory and a useful thing to log next to a fault; a value far below them is the overconfidence signature that motivated this work.

The retained (mahalanobis_sq, accepted) pairs, oldest first.

Forget channel's history, so no stale run can authorise inflation.

Called when a channel is re-enabled or when the filter is re-initialised, because in both cases the geometry that produced the history is gone and carrying it across would let a stale divergence authorise an inflation on a filter that has not earned it.

Forget every channel.

Channels with retained history, in insertion order.

Ratio of the observed distance to the gate threshold, capped.

The cap is max_inflation_factor and it is never lifted, including when the observed distance is not finite. A singular or ill-conditioned innovation covariance arrives here as inf, and inf is exactly the case where trusting the filter's own confidence has been disproved: the cost of the cap being wrong is that such a channel is faulted, while the cost of no cap is unbounded.

Per-axis covariance to add to the inflated block, in m^2 (or rad^2).

Two bounds apply and the tighter one wins.

The first is max_inflation_factor on the variance named by reacq_pos_sigma_m, a ceiling on what a single adaptation may add.

The second, and the one that does the real work, is the drift bound: the 1-sigma may not exceed max_drift_sigma_mps * blind_s, where blind_s is how long the channel has gone without an accepted measurement. A filter that has been blind for two seconds cannot have got a position more than about a metre wrong, so no offset beyond that is explainable as drift no matter how large a variance it is offered, and the re-gate will keep failing. That is the difference between bounding the increment and bounding the total, and between bounding the slack and bounding the error.

Degrees of freedom of an innovation vector, which is its length.

Chi-square upper-tail threshold for dof degrees of freedom.

Resolution order: the tabulated value, then the exact implementation for dof up to 8 where Wilson-Hilferty is not trustworthy, then Wilson-Hilferty for the large-dof tail.

The exact branch matters more than it looks. Wilson-Hilferty is documented above as unreliable at m = 4, 5 and outright invalid at m = 1; all three are dof values a relative-pose or scalar-aiding update can produce, so falling through to the approximation there would put a 5% error into a security-relevant threshold while the exact value costs one bisection on a path that is not hot.

Squared Mahalanobis distance y^T S^-1 y.

Solves rather than inverts: forming S^-1 explicitly squares the condition number in the forward error for no benefit, since only the quadratic form is needed.

S is symmetrised first. It is built as H P H^T + R from a covariance that is maintained symmetric, so in exact arithmetic it is already symmetric and the operation is a no-op. In floating point the products accumulate asymmetrically, and an asymmetric S makes the quadratic form depend on which triangle solve reads -- a difference of order the rounding error, which is the kind of discrepancy that makes a gate non-reproducible across platforms. Symmetrising makes the result depend only on the inputs.

Returns inf when S is unusable: non-finite, not positive definite, or conditioned beyond max_condition. Each of those means the measurement carries no trustworthy information in at least one direction, so no finite normalised distance exists. Callers reject, which is the safe direction -- see the module docstring.

Standard normal upper-tail quantile Phi^-1(1 - alpha).

Acklam's rational approximation, refined by one Newton step against :func:math.erf. The refinement matters because this value feeds a threshold that a safety decision is made on, and the raw approximation is only good to about 1e-9 relative -- good enough to be interesting, not good enough to be the last thing standing between a spoofed fix and a fused one. The Newton step costs one erf evaluation and lands at full double precision.

Note that the stdlib has math.erf but no math.erfinv, so the inverse cannot be had for free; S1 rules out SciPy's ndtri.

Wilson-Hilferty approximation to the 1 - alpha chi-square quantile.

The approximation is unreliable below a few degrees of freedom (it can go negative for m = 1 at large alpha) and becomes tight as m grows. dof is therefore required to be at least 2 here, and callers with dof == 1 are served by the exact table instead.

Configuration of the FDIR layer.

Every threshold the gate, the NIS monitor and the isolation state machine read is a field of :class:FdirConfig, so a run's behaviour is fixed by one serialisable object. The values are choices, not results; the evidence behind each default is in ADR-0005, ADR-0006 and ADR-0007.

Fault-detection policy. All thresholds are stated, none are implicit.

Significance level, the upper-tail probability of a false rejection.

The persistence monitor's configuration, derived from these fields.

Derived rather than nested so that :meth:as_dict stays flat and one number in one place governs both the estimator's configuration and the monitor built from it.

Records produced and kept by the FDIR layer.

:class:GatingDecision is the verdict on one update, :class:FdirEvent is a logged state transition, and :class:ChannelState is everything the manager remembers about one measurement channel. They are plain data with as_dict serialisers; the policy that fills them in is :class:navkit.fdir.fdir_manager.FdirManager.

Outcome of one innovation test.

accepted is the only field the estimator acts on. status carries the reason, because a filter that silently drops 40% of its GNSS fixes is indistinguishable from one that has degraded gracefully until you read a counter.

inflation_variance is the one field that asks the estimator to do something. When it is non-zero the estimator must add it to the diagonal of the state block named in inflation_block before forming the Kalman gain, and must recompute S from the inflated covariance -- not merely reuse the value the gate already saw. The gate's own re-gate in :meth:FdirManager.evaluate_and_adapt is computed from exactly that modification, so applying it as stated reproduces the distance the verdict was based on. Ignoring this field and fusing anyway would be a smaller change to the filter than honouring it, which is exactly why it is spelled out here.

A state transition worth logging rather than merely counting.

Per-channel counters. Public because the estimator reports them.

Sensor state machine: detect, isolate, recover.

Detection alone is not isolation

:mod:navkit.fdir.gating answers "is this measurement consistent with the filter's own uncertainty?" Turning that into a usable fault-handling layer takes three more decisions, and this module owns all three.

Transience. A single rejected update is ambiguous. It could be multipath on one epoch, or the first sign of a dead sensor, and the two demand opposite responses: one wants the next update, the other wants the sensor excluded. Distinguishing them on a single sample is not possible, so this module does not pretend to. It counts consecutive rejections and declares a fault only after max_consecutive_rejections of them, which is a deliberate trade of detection delay for immunity to single-epoch noise.

Isolation. Once a channel is declared faulty, what happens to its updates? The tempting answer -- inflate its noise until the innovation fits -- is exactly the failure mode ADR-0003 documents: it makes the filter confidently wrong rather than merely uncertain. A faulted channel is therefore rejected outright and its updates do not enter the filter. The filter coasts on inertial propagation, which is honest: no measurement, no information, and the covariance grows because nothing is constraining it.

Recovery. Isolation without a way back is a one-way ratchet that ends in a dead sensor. Recovery is symmetric hysteresis: auto_recovery_count consecutive accepted updates clear the fault. The asymmetry is intentional -- a fault needs several bad epochs to declare, and several good ones to clear, so a signal oscillating around the threshold cannot oscillate the state machine with it.

What this does not do

It does not tell you which kind of fault you have. Multipath inflates innovation variance without shifting the mean; a spoofed fix is internally consistent and agrees with nothing else; a drifting bias moves the mean slowly and takes longer to cross a threshold than a step does. Separating those needs cross-sensor consistency checks and residual whiteness tests over a window, which is ROADMAP Track A. This module is the isolation layer those detectors would report into, and it is honest about being the part that does the damage control rather than the part that does the diagnosis.

Fault-detection policy. All thresholds are stated, none are implicit.

Significance level, the upper-tail probability of a false rejection.

The persistence monitor's configuration, derived from these fields.

Derived rather than nested so that :meth:as_dict stays flat and one number in one place governs both the estimator's configuration and the monitor built from it.

Per-channel counters. Public because the estimator reports them.

A state transition worth logging rather than merely counting.

Outcome of one innovation test.

accepted is the only field the estimator acts on. status carries the reason, because a filter that silently drops 40% of its GNSS fixes is indistinguishable from one that has degraded gracefully until you read a counter.

inflation_variance is the one field that asks the estimator to do something. When it is non-zero the estimator must add it to the diagonal of the state block named in inflation_block before forming the Kalman gain, and must recompute S from the inflated covariance -- not merely reuse the value the gate already saw. The gate's own re-gate in :meth:FdirManager.evaluate_and_adapt is computed from exactly that modification, so applying it as stated reproduces the distance the verdict was based on. Ignoring this field and fusing anyway would be a smaller change to the filter than honouring it, which is exactly why it is spelled out here.

Chi-square gate, per-channel isolation, and adaptive re-acquisition.

One manager serves every channel and holds their states independently: a faulted GNSS receiver says nothing about the camera. The state is not thread safe, and does not need to be -- a filter's update path is single-threaded by construction here.

Two entry points, and the difference between them is the whole design: :meth:check is the plain gate, and :meth:evaluate_and_adapt adds the second pass that relieves a channel the filter itself has dislocated. Callers that cannot supply a covariance to inflate use check; the estimator uses evaluate_and_adapt.

State for sensor, created on first use.

Channels are created lazily rather than from a fixed enum so that adding a measurement model does not require editing the gate.

True while this channel is in an ADR-0007 spoof cooldown.

Separate from :meth:is_faulted because the two expire on different clocks: a fault clears on auto_recovery_count clean updates, a lockout only on elapsed time. A caller reporting "is this sensor usable" needs both.

Rolling mean innovation distance for sensor.

Exposed on the manager as well as the monitor because the diagnostics tool prints it per channel and a caller should not have to know the monitor is reachable.

Flat counters for the estimator's result dictionary.

Drop one channel's isolation state and its NIS history.

Both, and that is the point: re-enabling a sensor that the filter is no longer in the same place as would otherwise inherit the run of failures that disqualified it, and could be granted inflation on the strength of evidence gathered under a different geometry.

Gate one measurement and update the channel's isolation state.

Order of operations is deliberate. The innovation is gated first, even for an already-faulted channel: a faulted channel whose measurements are suddenly consistent again must be able to recover, and recovery is decided on how many good updates have arrived. Skipping the gate for a faulted channel would make recovery impossible, because every update would be excluded and nothing would ever be counted as good.

No covariance inflation happens here. Callers that have a covariance to offer should use :meth:evaluate_and_adapt; this method exists for the case where they do not, and for tests that want the bare gate.

Gate, and if the failure looks like the filter's own fault, re-gate.

This is the fix for blocker B5, and the reasoning behind both branches is the argument in :mod:navkit.fdir.nis_monitor.

A rejection is re-tested only when three things hold. The channel must have failed reacq_consecutive_rejections times running -- one bad measurement is not evidence of anything. The caller must have supplied P and H, because inflating a covariance you cannot see is not possible. And the re-gate must pass under the inflated covariance, not approximately.

The inflated innovation covariance is formed as S + H dP H^T rather than rebuilt from P and R. That is not an optimisation: it is exactly the value the estimator will have once it honours the decision, since S = H P H^T + R and the inflation adds to P only. Deriving it the other way would require the caller to also pass R and would create two chances for the re-gate and the fusion to disagree.

block names the state indices the measurement actually constrains. Inflating the whole 21x21 state on the strength of a position innovation would be both wrong and easy to get away with, because the result is still a valid covariance -- it would just be a filter that has suddenly become unsure of its own gyro bias.

The caller owns the units of the block as much as its identity. The variance computed here is derived from a translational drift rate in metres per second, so it is only meaningful for a translational block; the estimator restricts it to GNSS for that reason and for want of evidence on the others, and an empty block disables the second pass entirely rather than guessing.

Chi-square innovation gating: the statistics, with no SciPy.

A measurement update arrives with an innovation y and an innovation covariance S = H P H^T + R. If the filter is calibrated and the measurement is uncorrupted, then y ~ N(0, S) and the normalised squared distance

d_M^2 = y^T S^-1 y

is chi-square distributed with m = len(y) degrees of freedom. Rejecting an update whose d_M^2 exceeds the 1 - alpha quantile is therefore a decision with a stated false-alarm rate: at alpha = 0.01 a healthy sensor is wrongly rejected about one update in a hundred. That is a property worth having, and it is what distinguishes this from a hand-tuned residual threshold, which has no rate attached to it at all.

Why the thresholds are tabulated rather than computed

:mod:navkit.eval.statistics already computes exact integer-dof chi-square quantiles, and the module docstring there argues for computing rather than tabulating. The lookup table here is a deliberate exception with a specific reason: this code sits on the filter's inner loop, evaluated once per sensor per epoch, and it must be able to answer for any degrees of freedom without a bisection loop whose cost is not obviously bounded. The table covers the dof a navigation filter actually produces in the common case -- three position axes and the six axes of a full relative pose -- the exact implementation covers 1 to 8, and :func:wilson_hilferty covers the rest in closed form.

Every tabulated value has been checked against chi2_ppf to within 5e-4 (tests/test_fdir.py asserts this against the exact implementation rather than against the constants, so the table cannot silently drift from the maths). The table is rounded to the precision printed in standard references; using the exact value would be marginally better and would make the numbers unrecognisable to anyone checking them.

Wilson-Hilferty

For degrees of freedom outside the table and above 8, the cube-root normal approximation

chi2_{alpha,m}^2 ~ m (1 - 2/(9m) + z_alpha sqrt(2/(9m)))^3

is used, where z_alpha is the standard normal quantile. The exact implementation covers 1 to 8 rather than only the tabulated values, because Wilson-Hilferty's error is largest exactly where a relative-pose or scalar-aiding update operates. Measured against the exact quantiles the relative error runs from -0.33% at m = 4 to -0.005% at m = 60: worst in the far tail at low dof, which is why the threshold there is exact instead. The test suite pins both the bound and the direction rather than asserting a single "accurate enough" number.

Numerical conditioning

S is symmetrised before use and its conditioning is checked. An innovation covariance that is singular or near-singular means the measurement carries no independent information about some direction of the state -- which is a real condition for a degenerate relative-pose model (ADR-0003) and not a rounding curiosity. Such a covariance cannot support a Mahalanobis distance, so :func:mahalanobis_sq returns inf and the update is rejected. Returning a finite number from an ill-conditioned solve would be worse: it would be arbitrary, and arbitrary is indistinguishable from a real measurement.

Standard normal upper-tail quantile Phi^-1(1 - alpha).

Acklam's rational approximation, refined by one Newton step against :func:math.erf. The refinement matters because this value feeds a threshold that a safety decision is made on, and the raw approximation is only good to about 1e-9 relative -- good enough to be interesting, not good enough to be the last thing standing between a spoofed fix and a fused one. The Newton step costs one erf evaluation and lands at full double precision.

Note that the stdlib has math.erf but no math.erfinv, so the inverse cannot be had for free; S1 rules out SciPy's ndtri.

Wilson-Hilferty approximation to the 1 - alpha chi-square quantile.

The approximation is unreliable below a few degrees of freedom (it can go negative for m = 1 at large alpha) and becomes tight as m grows. dof is therefore required to be at least 2 here, and callers with dof == 1 are served by the exact table instead.

Chi-square upper-tail threshold for dof degrees of freedom.

Resolution order: the tabulated value, then the exact implementation for dof up to 8 where Wilson-Hilferty is not trustworthy, then Wilson-Hilferty for the large-dof tail.

The exact branch matters more than it looks. Wilson-Hilferty is documented above as unreliable at m = 4, 5 and outright invalid at m = 1; all three are dof values a relative-pose or scalar-aiding update can produce, so falling through to the approximation there would put a 5% error into a security-relevant threshold while the exact value costs one bisection on a path that is not hot.

Degrees of freedom of an innovation vector, which is its length.

Squared Mahalanobis distance y^T S^-1 y.

Solves rather than inverts: forming S^-1 explicitly squares the condition number in the forward error for no benefit, since only the quadratic form is needed.

S is symmetrised first. It is built as H P H^T + R from a covariance that is maintained symmetric, so in exact arithmetic it is already symmetric and the operation is a no-op. In floating point the products accumulate asymmetrically, and an asymmetric S makes the quadratic form depend on which triangle solve reads -- a difference of order the rounding error, which is the kind of discrepancy that makes a gate non-reproducible across platforms. Symmetrising makes the result depend only on the inputs.

Returns inf when S is unusable: non-finite, not positive definite, or conditioned beyond max_condition. Each of those means the measurement carries no trustworthy information in at least one direction, so no finite normalised distance exists. Callers reject, which is the safe direction -- see the module docstring.

Rolling NIS history, used to tell a spoof apart from a re-acquisition.

The chi-square gate in :mod:navkit.fdir.gating answers one question -- is this innovation consistent with what the filter expected? -- and it answers it the same way for every innovation. That is a weakness, not an oversight, and ADR-0005 names it: a gate on its own cannot tell multipath from spoofing from a filter that has become overconfident. All three look like the same large residual.

The distinguishing signal is not the size of the residual but its shape over time, and it is the only one that is available without a second sensor:

  • An isolated large residual on a channel whose recent residuals were nominal is a single-sample event. Multipath, a multipath-induced cycle slip, and a spoofed burst that the attacker chose not to sustain all look like this. There is no evidence of a systematic error, so the covariance must not be relaxed: inflating it here is exactly the "make the outlier fit" move that ADR-0003 documents as the failure mode of a filter that has stopped being able to say no.

  • A run of large residuals on a channel that is otherwise trusted points at the filter, not the sensor. The canonical case is re-acquisition: a 15 s denial walks the solution away from truth, the covariance keeps shrinking on process noise alone because nothing is correcting it, and when the honest fix arrives the filter is certain and wrong. Every innovation afterwards fails the gate, and a filter that only knows how to reject sits there accumulating error while the measurements that would rescue it pile up on the floor.

The second reading is the one that acts, and it carries the whole safety argument. Three things have to be true before this module authorises any relaxation, and each of them closes a hole the others leave:

  • the failures must be consecutive, so an old burst cannot keep a channel eligible indefinitely (is_persistent_divergence);
  • the channel must have actually been silent, so that the uncertainty it is being granted is bounded by how long the filter could plausibly have drifted (max_drift_sigma_mps) rather than by the size of the residual it produced;
  • and the sample must be a genuine outlier rather than a measurement the gate was already happy with, which a declared fault is holding out anyway (GatingDecision.outlier).

That last one is not a detail. max_inflation_factor on its own is not sufficient, and this module previously claimed it was; measurement contradicted that. A 1 m sustained spoof does not stay a 1 m innovation -- the filter is pulled toward it until the residual is comfortably inside the gate, at which point a second pass that could not distinguish the two exclusions spent the drift budget buying back a channel the first pass had correctly condemned. The offset that defeated it was smaller than the offset that was correctly refused. The cap still earns its place as a ceiling on any single grant, but the bound that does the work is the silence.

This module holds the second signal: a bounded window of recent outcomes per channel, and the decision built on top of it. It computes no statistics on the innovation itself -- that is gating's job, and this module takes the Mahalanobis distance as given. ADR-0006 records the reasoning and the alternatives rejected.

Tunables for the persistence monitor.

The defaults are chosen for measurement streams arriving at 5--20 Hz on a platform that has just lost one of its sensors, which is the case this exists for.

Per-channel rolling record of gate outcomes, and the divergence call.

One monitor is shared by every channel, keyed by channel name, because the two visual blocks are gated separately and a channel that has been rejected three times running says nothing about its neighbour.

Record one gate outcome for channel.

accepted is the verdict the gate already reached; this monitor does not re-judge it. Recording the distance as well as the verdict is what lets :meth:mean_nis answer "was this channel nominal before?", which is the comparison that separates an isolated impulse from a channel that has been off for a while.

True if the last K outcomes for channel were all rejections.

Reads only the trailing run, so an old burst of failures cannot keep a channel eligible for inflation indefinitely: the moment one update is accepted the count restarts.

Mean Mahalanobis distance over the retained window, or 0.0 if empty.

Reported rather than used in the divergence decision. Under a correct model this sits near the degrees of freedom, so a value far above them is a useful summary statistic for a trajectory and a useful thing to log next to a fault; a value far below them is the overconfidence signature that motivated this work.

The retained (mahalanobis_sq, accepted) pairs, oldest first.

Forget channel's history, so no stale run can authorise inflation.

Called when a channel is re-enabled or when the filter is re-initialised, because in both cases the geometry that produced the history is gone and carrying it across would let a stale divergence authorise an inflation on a filter that has not earned it.

Forget every channel.

Channels with retained history, in insertion order.

Ratio of the observed distance to the gate threshold, capped.

The cap is max_inflation_factor and it is never lifted, including when the observed distance is not finite. A singular or ill-conditioned innovation covariance arrives here as inf, and inf is exactly the case where trusting the filter's own confidence has been disproved: the cost of the cap being wrong is that such a channel is faulted, while the cost of no cap is unbounded.

Per-axis covariance to add to the inflated block, in m^2 (or rad^2).

Two bounds apply and the tighter one wins.

The first is max_inflation_factor on the variance named by reacq_pos_sigma_m, a ceiling on what a single adaptation may add.

The second, and the one that does the real work, is the drift bound: the 1-sigma may not exceed max_drift_sigma_mps * blind_s, where blind_s is how long the channel has gone without an accepted measurement. A filter that has been blind for two seconds cannot have got a position more than about a metre wrong, so no offset beyond that is explainable as drift no matter how large a variance it is offered, and the re-gate will keep failing. That is the difference between bounding the increment and bounding the total, and between bounding the slack and bounding the error.

Status codes and channel names shared by the FDIR modules.

These are plain strings so that a log line or a JSON result can be read back to a verdict without importing anything. They live apart from the policy in :mod:navkit.fdir.fdir_manager so that the configuration and the records that carry them can name them without importing the manager.