Roadmap¶
Sequenced by dependency, not by ambition. Each item names its exit test.
Status markers: [x] done, [~] in progress, [ ] not started, [!]
blocked.
Measured baseline: 1322 tests collected and 94.3% line coverage on CPython
3.13 (docs/data/metrics.json, from scripts/metrics.py and scripts/coverage_report.py). Every
number below re-measures against that, not against a remembered value.
Milestone 1: Honest foundation [x]¶
Stage 0: The project could build and read data [x]¶
- [x] Fix 6 quaternion-order defects across 4 readers and 1 writer
(
io/trajectory.py). TUM, Plotly and EuRoC readers all returned rotations of the wrong orientation;write_tumwrotew x y zinto columns labelledqx qy qz qw. Each result was orthonormal, so no structural check caught it. - [x] Fix
_read_rowstruncating rows to 8 columns, which made the EuRoC velocity branch unreachable dead code. - [x] Fix the EuRoC velocity slice reading
a[:, 14:17](accelerometer bias) instead ofa[:, 8:11]. - [x] Add reader/writer tests including round trips and a check against the
published TUM VI ATE (later replaced, see B4).
io/trajectory.py10.6% -> 90.8%. - [x] Add
README.md, whichpyproject.tomlreferenced but which did not exist, breaking the build. - [x] Add
.gitignore; correct the repository URLs inpyproject.toml. - [x] Record invariants and measured ratchets in
CONSTRAINTS.md.
Stage 1: 21-state ESKF, and the zero-coverage gaps [x]¶
The error state is 21: dtheta (3), dp (3), dv (3), db_g (3), db_a (3),
c_p (3), c_t (3). The last two are the visual anchor (translation and
rotation offset between the last visual frame and the current body frame),
carried as filter state rather than as measurement noise, because the anchor
drifts and a fixed measurement covariance cannot say so.
- [x] Tests for
config.py(0% -> 98.4%): schema validation, defaults, malformed input, unknown keys.Config.to_yaml()was writingdirat the top level instead of nested underoutput; fixed. - [x] Tests for
analysis/findings.py(0% -> 97.6%): claim record lifecycle, theFACT/HYPOTHESISdistinction, validation failures, report rendering.render_markdown()was discarding its body lines; fixed. - [x] Tests for
eval/metrics.py(17.8% -> 85.6%):rigid_startalignment semantics, association strategies, empty-input guards.ate_bundlemislabelledrigid_startasrigid; fixed. - [x] Tests for
eval/thresholds.py(18.1% -> 96.4%): detector hysteresis, false-alarm behaviour on a clean signal. - [x] Tests for
geometry/align.py(19.0% -> 76.6%): Umeyama, degenerate input. - [x] Cover
EskfConfig.as_dict()for the anchor prior and drift fields, which were serialised only partially. A config hash and a YAML round trip agreed with each other while disagreeing with the filter that ran. - [x] Joseph-form covariance update followed by a reset, never the
(I-KH) Pshortcut. ADR-0002. - [x] Commit
scripts/coverage_report.pyso theCONSTRAINTS.mdratchets are re-measurable by anyone. Its total floor now matches the 75% documented inCONSTRAINTS.md; it had been set to 44%, which made the gate weaker than the claim.
Stage 2: Visual observation Jacobians, corrected [x]¶
This is the defect worth reading the rest of the repository for.
-
[x] Fix the rotation and translation observation Jacobians, which were in the wrong frame. They read
H_theta = +IandH_ct = -Iwhere the conjugation identityrequires
H_theta = R_rel_predandH_ct = -R_rel_pred: the increment is conjugated into the previous body frame, so the identity is right only when the inter-frame rotation is zero. The translational blocks had the same fault for the same reason, sinceh(x) = R_prev^T (p_cur − p_prev)givesH_p = +R_prev^TandH_cp = -R_prev^T.At 20 Hz against a smooth trajectory this moved the benchmark only in the third decimal, which is why review missed it. Found by finite-difference tests written after the fact, against central differences of the filter's own residual. Recorded as R1 in
CONSTRAINTS.md. -
[x] Pin all four blocks numerically, so a frame or sign error cannot return quietly.
Not done: the 21-state reset Jacobian is covered by a positivity/symmetry regression rather than by a numerical derivative, because the reset is a covariance assignment rather than a differentiable map. Stated rather than left looking finished.
Stage 3: Reproducible experiment pipeline [x]¶
Numbers that cannot be regenerated are anecdotes. The benchmark tables were previously produced by ad-hoc scripts that were not in the repository.
- [x]
scripts/run_benchmark.pywith--all,--listand per-case selection, plus--markdownfor the published table. - [x]
configs/benchmark.yamlcapturing every seed, noise level, outage window and estimator setting behind each published row. - [x] JSON result schema with the seed recorded alongside every metric, plus a config hash so a result cannot be quoted without the config that made it.
- [x] Committed figures under
docs/figures/, regenerated by a single command and checked for drift in CI. - [x] Outage end-point semantics documented:
Outage.coversisstart <= t < end, so an outage ending at 20 s leaves the 20 s fix valid. Getting this wrong silently benchmarks a different scenario. - [x]
scripts/check_doc_tables.pycompares every numeric cell ofREADME.mdanddocs/results.mdagainst the generated JSON, so "these numbers are generated, not typed" is a checked claim rather than a promise. - [x] CI workflow: tests on Python 3.11/3.12/3.13, coverage ratchet, ruff lint and format, mypy, sdist/wheel build with a clean-env import, benchmark reproducibility (run twice, diff), documented-table check. Pages deploy workflow.
Stage 4: Documentation and epistemic framing [x]¶
- [x]
README.mdwith the motivating result and the limitation stated up front. - [x]
CONSTRAINTS.md,ROADMAP.md. - [x] ADR-0001 (21-state anchor), ADR-0002 (Joseph covariance form), ADR-0003 (defer the pose graph, ship visual disabled), ADR-0004 (internal quaternion order), ADR-0005 (chi-square FDIR, and the measured cost of it), ADR-0006 (NIS window monitor with adaptive covariance inflation, which paid that cost back).
- [x]
docs/architecture.md,docs/calibration.md, the documentation site (mkdocs.yml). - [x] GNSS-denial overconfidence framed explicitly as the headline negative
result rather than a caveat: mean NEES 286.2 against an expected 3, 20.5%
coverage against 99.3% expected,
vision_enabled = Falseshipped as the default, and the failure pinned bytest_gnss_denial_still_over_trusts_vision_and_that_is_pinned. - [x] Zero-dependency runtime: NumPy, PyYAML, Matplotlib, pytest. No SciPy, no
GTSAM, no factor-graph library, no compiled extension. Chi-square
quantiles and the incomplete gamma function are implemented in
eval/statistics.pybecause an unverifiable dependency is not a dependency.
Exit test met: a fresh clone runs python scripts/run_benchmark.py and
reproduces every published number to the displayed precision; CI fails if the
tables and the JSON disagree; a stranger can read the README, reproduce the
headline number, and understand the limitation without asking a question.
Milestone 2: Phase 2 roadmap [~]¶
Three tracks. The first two are engineering against the measured defect; the third is the documentation a programme needs and does not currently have.
Track A: FDIR module [~]¶
Today a degraded measurement is only visible after the fact, as a NEES number that has already been wrong. This track moves detection forward.
- [x] Innovation-based chi-square gating on the GNSS and visual innovations,
without a SciPy dependency. ADR-0005;
fdir/gating.pyholds the thresholds, tabulated for the dof a navigation filter produces and exact or Wilson-Hilferty beyond. 87.0% covered, table checked againsteval/statistics.pyrather than against itself. - [x] Per-channel fault state, exclusion after
max_consecutive_rejectionsconsecutive rejections, and recovery afterauto_recovery_countconsecutive accepted updates. Transitions are logged, transients are not. The two blocks of a relative-pose fix are gated separately, so a broken translation cannot be vouched for by a healthy rotation. - [x] Explicit false-alarm trade.
alpha = 0.001, because 0.99 confidence means 1% of healthy updates are rejected by definition, measured here at 13 of 1212. At 0.001 the same 1212 healthy fixes produce zero rejections and the broken configuration is still detected. Pinned bytest_a_clean_run_produces_zero_false_rejectionsand its visual equivalent; a gate that rejects everything fails only these. - [x] Exclusion rather than de-weighting. A rejected update returns before the
Kalman gain is formed, so
P^+ = P^-. InflatingRto cover a bad measurement is the failure ADR-0003 documents. - [x] An innovation-consistency monitor, and the fix for the collapsed-covariance
case it was written for (blocker B5, now R2). ADR-0006;
fdir/nis_monitor.pyholds a bounded per-channel window and reads a trailing run of rejections, not a count.outage_visual5.059 m to 2.541 m with rejections 51 to 5;outage_visual_degraded_camera3.339 m to 2.005 m (20% drop, key misspelled; corrected 30% measures 1.872 m; see ADR-0006). Pinned bytest_adaptive_inflation_recovers_the_fixes_that_the_plain_gate_threw_away, which asserts the old value as well so the regression fails on its own. - [x] The gate widened conditionally, not globally. The premise is tested rather than assumed: relief is available only when the channel was actually silent, and is bounded three ways: a factor cap, a drift rate per second of the channel's longest single silence, and a re-gate that must pass with headroom. One grant per divergence episode. The sustained offset earns no silence, so it earns no budget: verified at 40 m and 100 m with zero grants and no movement of the estimate.
- [!] B5's closure is not B1's. The aided case still over-trusts vision
(NEES 286.2, 20.5% coverage) because the anchor error is still
unmodelled, and recovering the GNSS fixes shrank the symptom without
repairing the cause. Related side effect worth watching: the ATE column
no longer separates this case from the honest 3.760 m control, so
CONSTRAINTS.mdnow requires the coverage and NEES columns to be read alongside it. - [!] Slow-ramp spoofing is not detectable with an IMU and GNSS alone: the filter followed every ramp from 0.05 to 2 m/s on two datasets (slow ramp, ADR-0019 accepted). The exit test below cannot be met for spoofing without a second independent source. A simulated visual source with independent errors lowers the floor to about 2 m/s, and not at all with correlated errors (vision ramp, ADR-0020 accepted).
- [~] Per-sensor classification from the GNSS innovations is measured (fault classification, ADR-0021 accepted): multipath is separable, spoofing is called in about three quarters of runs, weak faults are missed, and the control false-alarm rate is 18% in cross-validation and 35% on unseen runs.
- [x] Multipath signature as a function, not wired into the filter (multipath, ADR-0022): the specific rule calls multipath of about 3 m and above on injected noise and nothing else on fresh runs.
- [ ] Per-sensor detection of multipath (elevated innovation variance without
a mean shift), spoofing (innovation consistent but GNSS-internally
inconsistent, e.g. against the IMU-predicted position), and sensor
degradation (bias drift in
db_g/db_abeyond its declared prior). Today the gate detects implausibility; it does not yet distinguish which of those three causes produced it. - [ ] Extend
analysis/findings.pyso each detection is a typed claim with a measured detection rate and a measured false-alarm rate, not a demonstration that it fires once.
Exit test: on the degraded scenarios in degrade/, detection rate above
95% per fault type at a measured false-alarm rate of zero on the control, both
numbers reproduced by scripts/run_benchmark.py and checked into the docs
tables. Currently one fault type (a sustained position step, on either GNSS or
visual translation) is detected and isolated; the three causes behind it are
not yet separated. The fault matrix measures this and restates the
test: it is not met.
Track B: Back-end optimisation: sliding-window pose graph [~]¶
This is the structural fix for B1, the only genuine unknown in the project. Everything in Milestone 1 makes the limitation reproducible and documented; this removes it.
- [!] Blocker for the shipped configuration: a single-anchor ESKF cannot
represent correlated visual drift. Mean NEES 286.2 on
outage_visual, 20.5% coverage against 99.3% expected. (The figure was 1996.5 before ADR-0006; the number fell because the filter began taking its own uncertainty more seriously, not because the anchor is now modelled correctly. SeeCONSTRAINTS.md:B1.) - [x] Decide the formulation. A stochastic clone of the previous pose was tried first as the cheaper option with the same key property (ADR-0017, accepted as an opt-in). A sliding window or pose graph was not needed to meet the gate on the fixture.
- [x] NumPy-only implementation. S1 holds.
vision_model: clonereuses the six anchor slots as clone error states; the default is unchanged. - [x] Define "fixed" quantitatively before building: under the 15 s
GNSS-denied scenario, mean NEES below 10 and 2-sigma coverage above 90%.
The
outage_visual_clonerow meets it, and holds across the seed, trajectory and outage sweeps. - [x] Hold
vision_enabled = Falseuntil that gate is met. Decided in ADR-0017: the gate is met by the opt-in clone for a front end with independent errors,vision_enabledstaysFalse, and it is reconsidered when a front end's error correlation is measured. - [x] Keep the overconfident single-anchor case in the benchmark as a control row after the fix lands. Deleting the number that motivated the work would destroy the evidence that the work mattered.
- [x] A long vision-only run, to look for spurious information about global
position and yaw: none on the fixture. The claimed uncertainty keeps growing
and GNSS is accepted on return (
tests/test_clone_long_run.py). The first-estimate Jacobian or an observability-constrained update was not needed there. - [~] A visual front end with correlated errors, scale ambiguity and outliers. Outliers and scale drift are survived. Correlated errors are not, unless the assumed visual noise is inflated by a factor found by trial. A plain two-view front end was then measured on real frames (real front end; ADR-0023 and ADR-0024, which names the setting): its errors are correlated for 0.05 s to 0.5 s, and when they are replayed the clone stays inside the gate in all six windows of five Machine Hall sequences only with the assumed noise inflated by four. One dataset and one front end, so the default does not change.
Exit test: NEES below 10 with vision enabled and GNSS denied, coverage above
90%, vision_enabled = True as the shipped default. Current value 286.2 for the
shipped single anchor; the clone row is in the results table and meets the gate.
The default has not been changed.
Track C: TPM / PM deliverables [~]¶
The engineering is ahead of the paperwork, which is a schedule risk rather than a technical one.
- [x] SWaP-C trade-off matrix (
docs/product_management/02_swapc_tradeoff_matrix.md): size, weight, power, cost against accuracy, for the estimator configurations actually benchmarked. Computed fromEskfConfigand the runtime measurementsrun_benchmark.pyalready records, not estimated. - [x] System Requirements Specification (SRS) (
docs/product_management/01_system_requirements_spec.md): the interface, the failure modes, the detection requirements from Track A, and the acceptance thresholds from Track B, written as testable requirements. - [x] Sensor synchronisation and calibration specification
(
docs/product_management/04_sensor_sync_and_calibration_spec.md): the timing model between IMU, GNSS and camera streams; the time-offset handling; the intrinsic and extrinsic calibration assumptions; what is measured on the recordings versus what is assumed in simulation. The recordings' timestamp regularity and ground-truth-to-IMU offset are measured, not assumed. - [x] The documents above live in
docs/product_management/, together with the FDIR and spoofing strategy (03_fdir_and_spoofing_strategy.md). The risk log isRISKS.md. The work breakdown is05_work_breakdown.md.
Exit test: a reviewer can trace every requirement in the SRS to a test, a
configuration, or an explicitly declared gap. The SRS has a traceability matrix
(section 2), now checked by scripts/check_srs_trace.py against docs/product_management/srs_trace.csv:
every requirement and criterion has a test, a checked configuration value or a declared gap, and the
roll-up counts follow from the verdicts. The check is structural; it does not re-measure a number.
Phase 5 backlog¶
The next work is ordered by RICE score in docs/prioritisation.md. The inputs are judgements, and the page shows how stable the order is.
Where each item stands:
- Done: P5-01 (noise-mismatch sweep), P5-02 (fault matrix), P5-04 (velocity process noise, bias sigmas), P5-05 (the drift keys renamed per square-root second, with an alias), P5-06 (the accelerometer-bias falsification experiment, which falsified the hypothesis on the fixture), P5-07 (sensor synchronisation and calibration specification), P5-08 (SRS traceability check), P5-10 (release hygiene), P5-13 (a TUM VI fetch path).
- Spike done, opt-in: P5-03 (the stochastic clone, ADR-0017). Calibrated for independent visual errors, not for correlated ones. The shipped configuration is unchanged.
- Done as decided: P5-09 (the stale figures in docstrings are fixed; every baseline record is a dated
point-in-time snapshot with a banner saying so, excluded from the site and deliberately not rewritten, because their
old figures are what the remediation was measured against) and P5-12 (no pull request or Dependabot item is open,
uv.lockwas removed). - Not started: P5-11 (a comparison with an established consistent estimator, which strains constraint S1).
Open blockers¶
CONSTRAINTS.md holds the authoritative list. In short:
- B1: visual fusion is overconfident under GNSS denial. Needs Track B.
- B4 is resolved (R5 in
CONSTRAINTS.md): the claim of a reproduction of the published 0.069 m is retired and replaced by a gyroscope check on recorded data.
Deliberately not planned¶
- Real sensor drivers, SLAM front ends, or a ROS integration. Out of scope; the project is an evaluation and calibration harness.
- Learned uncertainty models. Would undercut the point: the question is whether a classical estimator is honest about itself.
- Large vendored datasets. Breaks S2 and makes the repository unusable offline.