Fault detection¶
navkit.fdir
¶
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.
ChannelState
dataclass
¶
Per-channel counters. Public because the estimator reports them.
FdirConfig
dataclass
¶
Fault-detection policy. All thresholds are stated, none are implicit.
alpha
property
¶
Significance level, the upper-tail probability of a false rejection.
nis_config()
¶
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.
FdirEvent
dataclass
¶
A state transition worth logging rather than merely counting.
FdirManager
¶
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(sensor)
¶
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.
is_locked_out(sensor)
¶
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.
mean_nis(sensor)
¶
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.
stats()
¶
Flat counters for the estimator's result dictionary.
reset_channel(sensor)
¶
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.
check(sensor, innovation, S, t_s=0.0)
¶
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.
evaluate_and_adapt(sensor, innovation, S, t_s=0.0, P=None, H=None, block=())
¶
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.
GatingDecision
dataclass
¶
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.
NisConfig
dataclass
¶
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.
NisWindowMonitor
¶
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.
add_sample(channel, mahalanobis_sq, accepted)
¶
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.
is_persistent_divergence(channel)
¶
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_nis(channel)
¶
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.
history(channel)
¶
The retained (mahalanobis_sq, accepted) pairs, oldest first.
reset_channel(channel)
¶
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.
reset()
¶
Forget every channel.
channels()
¶
Channels with retained history, in insertion order.
inflation_factor(mahalanobis_sq, threshold)
¶
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.
inflation_variance(mahalanobis_sq, threshold, blind_s=0.0)
¶
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.
chi2_dof(y)
¶
Degrees of freedom of an innovation vector, which is its length.
chi2_threshold(dof, alpha)
¶
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.
mahalanobis_sq(y, S, max_condition=MAX_CONDITION_NUMBER)
¶
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.
normal_quantile(alpha)
¶
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(alpha, dof)
¶
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.
navkit.fdir.config
¶
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.
FdirConfig
dataclass
¶
Fault-detection policy. All thresholds are stated, none are implicit.
alpha
property
¶
Significance level, the upper-tail probability of a false rejection.
nis_config()
¶
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.
navkit.fdir.records
¶
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.
GatingDecision
dataclass
¶
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.
FdirEvent
dataclass
¶
A state transition worth logging rather than merely counting.
ChannelState
dataclass
¶
Per-channel counters. Public because the estimator reports them.
navkit.fdir.fdir_manager
¶
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.
FdirConfig
dataclass
¶
Fault-detection policy. All thresholds are stated, none are implicit.
alpha
property
¶
Significance level, the upper-tail probability of a false rejection.
nis_config()
¶
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.
ChannelState
dataclass
¶
Per-channel counters. Public because the estimator reports them.
FdirEvent
dataclass
¶
A state transition worth logging rather than merely counting.
GatingDecision
dataclass
¶
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.
FdirManager
¶
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(sensor)
¶
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.
is_locked_out(sensor)
¶
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.
mean_nis(sensor)
¶
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.
stats()
¶
Flat counters for the estimator's result dictionary.
reset_channel(sensor)
¶
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.
check(sensor, innovation, S, t_s=0.0)
¶
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.
evaluate_and_adapt(sensor, innovation, S, t_s=0.0, P=None, H=None, block=())
¶
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.
navkit.fdir.gating
¶
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.
normal_quantile(alpha)
¶
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(alpha, dof)
¶
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.
chi2_threshold(dof, alpha)
¶
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.
chi2_dof(y)
¶
Degrees of freedom of an innovation vector, which is its length.
mahalanobis_sq(y, S, max_condition=MAX_CONDITION_NUMBER)
¶
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.
navkit.fdir.nis_monitor
¶
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.
NisConfig
dataclass
¶
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.
NisWindowMonitor
¶
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.
add_sample(channel, mahalanobis_sq, accepted)
¶
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.
is_persistent_divergence(channel)
¶
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_nis(channel)
¶
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.
history(channel)
¶
The retained (mahalanobis_sq, accepted) pairs, oldest first.
reset_channel(channel)
¶
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.
reset()
¶
Forget every channel.
channels()
¶
Channels with retained history, in insertion order.
inflation_factor(mahalanobis_sq, threshold)
¶
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.
inflation_variance(mahalanobis_sq, threshold, blind_s=0.0)
¶
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.
navkit.fdir.status
¶
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.