Architecture¶
The question¶
The estimator is the test subject, not the product. navkit asks one
question of a navigation filter: is its stated uncertainty honest?
Everything in eval/ exists to answer that. Everything in estimators/
exists to be asked it.
Layers¶
flowchart LR
syn["synthetic.py<br/>analytic trajectory (the truth)"] --> sens["sensors/models.py<br/>IMU, GNSS, visual streams"]
sens --> deg["degrade/inject.py<br/>outages, drops, noise"]
deg --> eskf["estimators/eskf.py<br/>21-state ESKF + covariance"]
eskf --> cal["eval/calibration.py<br/>NEES, coverage, verdicts"]
syn --> cal
Supporting layers:
geometry/: SE(3) transforms, quaternions, Umeyama alignment. Convention owner: everything is(w, x, y, z)andT_wb(ADR-0004).io/: TUM, EuRoC and Plotly readers and writers. The only place that converts between quaternion orders.io/imu.py: IMU noise models. Bias random walk, scale factor, axis misalignment, white noise.analysis/findings.py: typed claim records (FACT,MEASUREMENT,INTERPRETATION,HYPOTHESIS) with validation, so published claims are structurally distinguishable from assertions.config.py: application configuration loading and validation.
The estimator¶
State vector (21)¶
| Index | Symbol | Meaning | Units |
|---|---|---|---|
| 0:3 | dtheta |
orientation error | rad |
| 3:6 | dp |
position error | m |
| 6:9 | dv |
velocity error | m/s |
| 9:12 | db_g |
gyroscope bias | rad/s |
| 12:15 | db_a |
accelerometer bias | m/s^2 |
| 15:18 | c_p |
visual anchor position offset | m |
| 18:21 | c_t |
visual anchor attitude offset | rad |
The first 15 are the conventional error-state IMU/GNSS ESKF. The last six are the visual anchor from ADR-0001.
Prediction¶
Standard error-state IMU propagation with a 21x21 transition matrix. The
anchor states are constant in the body frame and therefore carry no process
noise by default. Optional drift terms
(anchor_pos_drift_sigma_m_sqrt_s, anchor_rot_drift_sigma_deg_sqrt_s) exist but
default to 0.0; see ADR-0003 for why they are not the fix.
Visual update¶
A relative visual measurement is expressed in the previous camera frame:
This is not a measurement of world position. The translation half constrains the
constant body-frame position offset c_p, and the rotation half constrains the
constant body-frame attitude offset c_t. The Jacobians encode that
distinction:
| Block | Jacobian | Rationale |
|---|---|---|
| rotation | H_theta = R_rel_pred |
the increment is conjugated into the previous body frame |
| anchor rotation | H_ct = -R_rel_pred @ J_r(c_t) |
the anchor error enters with the opposite sense, same frame; J_r is the right Jacobian of SO(3), since the perturbation enters on the right of Exp(c_t) and J_r(0) = I |
| translation | H_p = R_prev^T, H_cp = -R_prev^T |
the same relative quantity constrains position and the anchor in opposite senses |
The asymmetry in signs is the whole point. -R_rel_pred with +R_rel_pred is
what makes the anchor absorb the measurement instead of the navigation state
collapsing.
Note that the rotation blocks are R_rel_pred and not the identity. The
identity is correct only in the special case R_rel_pred == I, because
rot_log(Q Exp(v) Q^T) == Q v: the conjugation identity rotates the increment
into the previous body frame, and that is the frame the residual is expressed
in. Getting this wrong is a frame error rather than a sign error, so it is
harder to spot and it stays small whenever the inter-frame rotation is small.
On the first accepted visual measurement the filter is initialised: the
anchor is set from the measurement, no residual is applied to the navigation
state, and the anchor covariance is seeded from the declared prior
(anchor_pos_sigma_m, anchor_rot_sigma_deg). Subsequent measurements
constrain the anchor normally.
Covariance¶
Posterior covariance uses the Joseph form (ADR-0002):
followed by the reset Jacobian G. The non-Joseph shortcut (I - K H) P is
not used anywhere; it is not guaranteed positive semidefinite in floating
point and was reachable in practice.
Anchor covariance is re-committed on vision_keyframe_interval visual
updates, which defaults to 1. The prior is installed, and cross-covariance
with the navigation block is set to zero. That zeroing is the honest
conservative choice; a perfectly-correlated alternative was tested and
produces a degenerate covariance (ADR-0003).
Gating¶
Visual updates are gated on their innovation. GNSS updates are gated separately, and their accept/reject counts are reported as a first-class metric: in the defect configuration, 46 of 101 GNSS updates were rejected because the visual update had already collapsed the position covariance. That coupling was the diagnostic that exposed the original bug, so the counters are part of the public result, not debug output.
FDIR¶
fdir/ answers a question the residual gate cannot: not "is this update
implausible" but "has this sensor stopped producing usable data". Every
update is tested with a chi-square innovation gate before anything is
applied.
y, S = H P H^T + R
|
v
d_M^2 = y^T S^-1 y via Cholesky; inf if S is not SPD
| or is conditioned past 1e12
v
d_M^2 <= chi2_{1-alpha, m}?
/ \
yes no
| |
ACCEPTED consecutive_rejections += 1
|
+------------+------------+
| |
< max_consecutive >= max_consecutive
| |
REJECTED_TRANSIENT REJECTED_PERSISTENT
-> SENSOR_FAULT: channel excluded
|
consecutive_accepts >= auto_recovery_count
|
channel returns to ACCEPTED
A rejected update returns before the Kalman gain is formed: P^+ = P^-
and the state is untouched. The alternative (inflating R until the
innovation fits) is the failure ADR-0003 documents, where the filter ends
up confidently wrong rather than merely uncertain.
Three properties are load-bearing and each has a test named after it:
- The false-alarm rate is stated, not chosen.
alpha = 0.001is the default, not the conventional 0.01, because 0.01 rejects 1% of healthy updates by definition, measured here at 13 of 1212 healthy fixes. Seegating.DEFAULT_CONFIDENCE. - Rotation and translation are gated separately. They arrive as two
blocks of one transform, so a single
visionchannel would let the healthy half vouch for the broken one. At a 10 m translation step the translation channel faults and the rotation channel records nothing. - A fault clears. A channel excluded by a 20-frame step is not a dead
sensor. Recovery requires
auto_recovery_countconsecutive accepted updates, and the counter for "updates actually used" stops crediting whatever the gate refused.
The premise is a calibrated innovation covariance, and this filter is not calibrated in exactly the configurations that matter; see the benchmark note below and blocker B4.
Evaluation¶
eval/calibration.py is the centre of the project.
- NEES: normalised estimation error squared,
e^T P^-1 e, against a chi-square distribution withndegrees of freedom. A well-calibrated filter sits near the nominal value. - 2-sigma coverage: the fraction of steps where the true error falls inside the claimed 2-sigma ellipsoid. The most direct read on whether a downstream consumer can trust the output.
- Calibration verdict: over/underconfident classification with a Wilson interval on the coverage fraction, so a verdict is not declared from a handful of steps.
- Conformal radius: empirical coverage-preserving radius at a chosen confidence, which is what you would actually hand to a downstream planner.
eval/statistics.py implements exact integer-dof chi-square quantiles
directly, so the project has no SciPy dependency for its central claim
(S1).
eval/metrics.py provides trajectory-level metrics: ATE under several
alignment conventions, relative pose error, drift, and time-to-recovery.
Alignment convention matters and is always reported, never implied: an ATE
without its alignment is not a result.
Rejected design: convex combination¶
A linear blend of GNSS and visual estimates, T = T_gnss (w) inv(T_vis (1-w)),
was implemented and measured. It gives a smooth trajectory and consistently
understates variance, because it ignores the correlation between the two
sources. A filter that agrees with this baseline on trajectory smoothness
while disagreeing on variance is telling you the variance is the hard part,
not the trajectory.
Known limitation¶
Visual fusion is overconfident under GNSS denial; see ADR-0003. navkit
ships with vision_enabled = False for that reason. The fix is a pose graph
over visual keyframes, tracked in ROADMAP.md stage 3.
FDIR interacts with that limitation rather than being independent of it. In
outage_visual, the 15 s denial ends with the filter displaced and holding a
collapsed covariance, and the ADR-0005 gate rejected the returning GNSS fixes
at t = 20.8 s onwards (51 of them) because a gate that trusts a
covariance of a few centimetres reads a healthy 3 m fix as an outlier. ATE
3.428 m without FDIR, 5.059 m with it. The gate was behaving as specified; the
specification assumed calibration the filter did not have. That was blocker B5.
ADR-0006 fixed it without touching the false-alarm rate. FdirManager keeps
the ADR-0005 gate as the first pass and adds a second one, reached only when a
per-channel NIS window (fdir/nis_monitor.py) reports a trailing run of
rejections and the channel was actually silent. It then inflates the GNSS
position block, recomputes S' = S + H ΔP Hᵀ (the covariance the estimator
will actually have, not an approximation of it) and re-gates with headroom. The
grant is bounded by a factor cap, by a drift rate per second of the channel's
longest single silence, and by one grant per episode. In outage_visual this
takes ATE 5.059 m to 2.541 m and rejections 51 to 5.
A channel streaming at 5 Hz has no silence, so it earns no budget and a
sustained spoof never becomes plausible: verified at 40 m and 100 m with zero
grants. Pinned by test_adaptive_inflation_recovers_the_fixes_that_the_plain_gate_threw_away
and tests/test_nis_monitor.py. The residual overconfidence is the unmodelled
anchor error above, which this does not touch.