mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 21:21:14 +00:00
Compare commits
7 Commits
2ce300ddb1
...
06f655d8fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f655d8fb | |||
| f12789ebf0 | |||
| ac3e288dbd | |||
| 21cef8bdce | |||
| ceb24b5a62 | |||
| 4815dd6aa1 | |||
| 6a5954bdae |
@@ -1,8 +1,8 @@
|
||||
# Dependencies Table
|
||||
|
||||
**Date**: 2026-05-14 (refreshed after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; earlier 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Total Tasks**: 148 (107 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene
|
||||
**Total Complexity Points**: 491 (358 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt
|
||||
**Date**: 2026-05-14 (refreshed after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling` → `Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
|
||||
**Total Tasks**: 149 (108 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene
|
||||
**Total Complexity Points**: 494 (361 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt
|
||||
|
||||
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||
text in each task spec is omitted here for table-readability). The
|
||||
@@ -160,6 +160,7 @@ are all declared and documented below under **Cycle Check**.
|
||||
| AZ-508 | Hygiene — consolidate `_iso_ts_now` helpers into `helpers/iso_timestamps.py` | 2 | AZ-263 | AZ-264 |
|
||||
| AZ-526 | Hygiene — add `iso_ts_from_clock(clock)` to `helpers/iso_timestamps.py` | 2 | AZ-508, AZ-398 | AZ-264 |
|
||||
| AZ-527 | Hygiene — consolidate `_assert_engine_output_dim` into c2-internal helper | 2 | AZ-340 | AZ-255 |
|
||||
| AZ-528 | Hygiene — consolidate c1_vio strategy facade orchestration spine | 3 | AZ-334 | AZ-254 |
|
||||
| AZ-523 | Batch 44 — C11 internal flight-state gate removal (SRP refactor; audit-trail; closed) | 3 | AZ-317, AZ-319, AZ-329 | AZ-251 |
|
||||
| AZ-524 | Batch 44 — C12 package rename: c12_operator_tooling → c12_operator_orchestrator (audit; closed)| 2 | AZ-263, AZ-326, AZ-327, AZ-328, AZ-329, AZ-330, AZ-489 | AZ-253 |
|
||||
|
||||
@@ -247,6 +248,18 @@ are all declared and documented below under **Cycle Check**.
|
||||
- **E-C12 epic (AZ-253) summary renamed**:
|
||||
`C12 Operator Pre-flight Tooling` →
|
||||
`C12 Operator Pre-flight Orchestrator`.
|
||||
- **Hygiene PBI from cumulative review batches 52-54** (added
|
||||
2026-05-14):
|
||||
- **AZ-528** (E-C1 / AZ-254) — c1_vio strategy facade
|
||||
orchestration-spine 3-way duplication. Depends on AZ-334 (the
|
||||
trigger that escalated the duplication from 2-way to 3-way; all
|
||||
3 callers now exist in `done/`). NOT a gate on AZ-335 in a strict
|
||||
technical sense, but **recommended to sequence before AZ-335** so
|
||||
the warm-start path lands against the consolidated spine instead
|
||||
of a fourth divergent copy. 3 points: 1 new module
|
||||
(`c1_vio/_facade_spine.py`) + 3 small edits + 1 test file with
|
||||
AST-walk + import-grep regression guards. Pattern mirrors
|
||||
AZ-527's c2-side consolidation.
|
||||
- **Hygiene PBIs from cumulative review batches 31-33** (added
|
||||
2026-05-13):
|
||||
- **AZ-507** (E-CC-CONF / AZ-246) — module-layout.md ↔ AZ-270 lint
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Hygiene — Consolidate c1_vio strategy facade orchestration spine
|
||||
|
||||
**Task**: AZ-528_hygiene_c1_vio_facade_spine_consolidation
|
||||
**Name**: c1_vio strategy facade orchestration-spine helper consolidation
|
||||
**Description**: Replace the 3-way byte-equivalent orchestration-spine duplication across the c1_vio `VioStrategy` modules (`okvis2.py`, `vins_mono.py`, `klt_ransac.py`) with a single c1-internal helper module at `src/gps_denied_onboard/components/c1_vio/_facade_spine.py`. Closes cumulative review batches 52-54 Finding F1 (Medium / Maintainability).
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-334 (the trigger that escalated the duplication from 2-way to 3-way; all 3 callers now exist). AZ-332 + AZ-333 are already in `done/`.
|
||||
**Component**: c1_vio (epic AZ-254 / E-C1)
|
||||
**Tracker**: AZ-528
|
||||
**Epic**: AZ-254 (E-C1)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/03_implementation/cumulative_review_batches_52-54_cycle1_report.md` § F1 — the finding being closed.
|
||||
- `_docs/02_document/components/01_c1_vio/description.md` — the c1_vio component description (helper goes inside the c1_vio boundary).
|
||||
- `_docs/02_tasks/done/AZ-332_c1_okvis2_strategy.md`, `AZ-333_c1_vins_mono_strategy.md`, `AZ-334_c1_klt_ransac_strategy.md` — the three task-specs whose existing AC tests must continue to pass unmodified after the consolidation.
|
||||
|
||||
## Problem
|
||||
|
||||
Three c1_vio `VioStrategy` modules each duplicate the same orchestration-spine helpers:
|
||||
|
||||
**Module-level free functions (byte-identical across all 3)**:
|
||||
|
||||
| Function | File | Notes |
|
||||
|----------|------|-------|
|
||||
| `_now_iso() -> str` | `okvis2.py`, `vins_mono.py`, `klt_ransac.py` | ISO-8601 UTC timestamp for FDR `ts` |
|
||||
| `_bias_norm(bias: ImuBias) -> float` | `okvis2.py`, `vins_mono.py`, `klt_ransac.py` | L2 norm of `(accel ‖ gyro)` 6-vector |
|
||||
| `_se3_from_4x4(matrix) -> gtsam.Pose3` | `okvis2.py`, `vins_mono.py`, `klt_ransac.py` | Lazy gtsam import + np-array coerce |
|
||||
| `_frame_ts_ns(frame: NavCameraFrame) -> int` | `okvis2.py`, `vins_mono.py` | UTC-epoch ns from `frame.timestamp` (KLT/RANSAC uses inline access) |
|
||||
| `_frame_image(frame: NavCameraFrame) -> np.ndarray` | `okvis2.py`, `vins_mono.py` | Contiguous uint8 ndarray + 2D/3D shape check (KLT/RANSAC owns inline grayscale conversion) |
|
||||
|
||||
**Instance methods (byte-identical modulo strategy-local config-knob references + module-level constants)**:
|
||||
|
||||
| Method | Files | Differences |
|
||||
|--------|-------|-------------|
|
||||
| `_classify_state(self, fq) -> VioState` | all 3 | Threshold attribute reference: `self._okvis2_cfg.degraded_feature_threshold` vs `self._cfg.min_features_for_pose` |
|
||||
| `_tick_lost(self, frame_id)` | all 3 | Byte-identical — increments `_consecutive_lost`, escalates to LOST at `_lost_frame_threshold` |
|
||||
| `_emit_transition(self, new_state, frame_id)` | all 3 | Differs only in module-level `_PRODUCER_ID` + `_STRATEGY_LABEL` constants captured at emit time |
|
||||
|
||||
The geometry-specific pipeline (OKVIS2 cascade init + native binding driver, VINS-Mono loosely-coupled estimator driver, KLT/RANSAC seed/track/recoverPose ladder) is unique to each strategy and stays inside each strategy module — this PBI does not touch the geometry pipelines.
|
||||
|
||||
The per-batch reviews for B53 + B54 explicitly deferred consolidation to the next cumulative review (batches 52-54). That cumulative review formally raised the finding from Low to Medium and opened this ticket.
|
||||
|
||||
## Outcome
|
||||
|
||||
- A new c1-internal module `src/gps_denied_onboard/components/c1_vio/_facade_spine.py` exposes:
|
||||
- Free functions: `now_iso()`, `bias_norm(bias)`, `se3_from_4x4(matrix)`, `frame_ts_ns(frame)`, `frame_image(frame, *, producer_id)`.
|
||||
- A `FacadeSpine` mixin class providing `_classify_state(self, fq) -> VioState`, `_tick_lost(self, frame_id) -> None`, `_emit_transition(self, new_state, frame_id) -> None`. The mixin reads strategy-specific values via per-instance attributes that each concrete strategy must set: `_feature_threshold: int`, `_warm_start_max_frames: int`, `_lost_frame_threshold: int`, `_producer_id: str`, `_strategy_label: str`, plus the existing `_reported_state`, `_frames_since_warmup`, `_consecutive_lost`, `_last_emitted_state`, `_latest_bias`, `_fdr` attributes.
|
||||
- The three c1_vio strategy modules import the helpers + inherit from the mixin. The duplicated bodies are deleted. The deferred-consolidation comments referencing this PBI are also deleted.
|
||||
- A new unit test `tests/unit/c1_vio/test_az528_facade_spine.py` covers AC-1..AC-7 below.
|
||||
- The existing AZ-332 / AZ-333 / AZ-334 AC tests pass unmodified.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- Add `src/gps_denied_onboard/components/c1_vio/_facade_spine.py` with the listed free functions + `FacadeSpine` mixin. Import surface: only L1/L2 substrate (`_types.nav`, `components.c1_vio.errors`, `_types.fdr_record`, stdlib datetime + math + numpy).
|
||||
- Migrate the three c1_vio strategy modules to import the free functions and inherit from `FacadeSpine`. Delete the duplicated bodies + any in-line `post-AZ-334 hygiene` / `Batch 53 review F1` / `Batch 54 review F1` deferred-consolidation comments.
|
||||
- Add `tests/unit/c1_vio/test_az528_facade_spine.py` with AC-1..AC-7 (3 free-function tests + 3 mixin-behaviour tests + 1 regression-guard test).
|
||||
- Re-run the existing `tests/unit/c1_vio/test_okvis2_strategy.py`, `test_vins_mono_strategy.py`, `test_klt_ransac_strategy.py` AC sub-tests unmodified to verify the consolidation preserves behaviour at every call site.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Sharing the spine across other components (c2, c3, c4, c5). Each component owns its own FDR record kind + state machine; sharing across components would entangle the component boundaries that AZ-507 carved cleanly.
|
||||
- Refactoring the strategy-specific geometry pipelines (OKVIS2 cascade init, VINS-Mono estimator driver, KLT/RANSAC pose-recovery ladder). Those are correctly per-strategy.
|
||||
- Changing the `vio.health` FDR record schema, the `VioState` enum values, or the `CURRENT_SCHEMA_VERSION` constant.
|
||||
- Refactoring the test fakes in `tests/unit/c1_vio/conftest.py` (`FakeOkvis2Backend` / `FakeVinsMonoBackend`) or the `_patch_pose_recovery` helper in `test_klt_ransac_strategy.py`. These are F2 from cumulative review batches 52-54; they sit at a different abstraction layer and will ride a later hygiene pass.
|
||||
- Hoisting the spine to `src/gps_denied_onboard/helpers/`. Strategy state machines + FDR record producers are c1-internal concerns by component-architecture decision.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Helper module exists at the canonical path with the expected surface**
|
||||
Given a fresh checkout
|
||||
When `from gps_denied_onboard.components.c1_vio._facade_spine import now_iso, bias_norm, se3_from_4x4, frame_ts_ns, frame_image, FacadeSpine` is run
|
||||
Then all imports succeed; `FacadeSpine` is a class; `now_iso`, `bias_norm`, `se3_from_4x4`, `frame_ts_ns`, `frame_image` are callables; `FacadeSpine` has methods `_classify_state`, `_tick_lost`, `_emit_transition`
|
||||
|
||||
**AC-2: `now_iso()` returns ISO-8601 UTC**
|
||||
Given the helper imported
|
||||
When `now_iso()` is called
|
||||
Then the return value is a string that parses as an aware UTC `datetime` via `datetime.fromisoformat(...)` and the offset is `+00:00` (matching the existing OKVIS2 / VINS-Mono / KLT-RANSAC behaviour — NOT the `Z`-suffix variant; that is `iso_ts_from_clock` in AZ-526)
|
||||
|
||||
**AC-3: `bias_norm(bias)` matches the L2 formula**
|
||||
Given an `ImuBias(accel_bias=(1.0, 2.0, 2.0), gyro_bias=(0.0, 0.0, 0.0))`
|
||||
When `bias_norm(bias)` is called
|
||||
Then the result equals `3.0` (sqrt(1+4+4))
|
||||
|
||||
**AC-4: `se3_from_4x4(matrix)` builds a `gtsam.Pose3`**
|
||||
Given a 4×4 identity matrix
|
||||
When `se3_from_4x4(np.eye(4))` is called
|
||||
Then the returned object is a `gtsam.Pose3` whose translation is `(0, 0, 0)` and whose rotation is the identity
|
||||
|
||||
**AC-5: `FacadeSpine._classify_state` mirrors the existing logic across all three strategies**
|
||||
Given a `FacadeSpine` subclass instance with `_reported_state=INIT`, `_frames_since_warmup=0`, `_warm_start_max_frames=5`, `_feature_threshold=50`
|
||||
When `_classify_state(FeatureQuality(tracked=80))` is called
|
||||
Then the return is `VioState.INIT`; after `_frames_since_warmup=5`, the same call returns `VioState.TRACKING`; with `tracked=10`, it returns `VioState.DEGRADED`
|
||||
|
||||
**AC-6: `FacadeSpine._tick_lost` transitions correctly**
|
||||
Given `_reported_state=TRACKING`, `_consecutive_lost=0`, `_lost_frame_threshold=3`
|
||||
When `_tick_lost("frame_42")` is called once
|
||||
Then `_reported_state == VioState.DEGRADED`; calling 2 more times escalates `_reported_state == VioState.LOST`
|
||||
|
||||
**AC-7: `FacadeSpine._emit_transition` emits exactly one FDR record per state change**
|
||||
Given `_last_emitted_state=TRACKING`, a fake `FdrClient`
|
||||
When `_emit_transition(VioState.TRACKING, "frame_42")` is called (no state change)
|
||||
Then no record is enqueued
|
||||
When `_emit_transition(VioState.DEGRADED, "frame_42")` is called (state change)
|
||||
Then exactly one FDR record is enqueued with `kind="vio.health"`, the payload contains `{state, consecutive_lost, bias_norm, strategy_label, frame_id}`, and `_last_emitted_state == VioState.DEGRADED`
|
||||
|
||||
**AC-8: No stray duplicated definitions remain in the three strategy modules**
|
||||
Given `grep -rn "^def _now_iso\|^def _bias_norm\|^def _se3_from_4x4\|^def _frame_ts_ns\|^def _frame_image" src/gps_denied_onboard/components/c1_vio/` after the task lands
|
||||
When the search runs
|
||||
Then matches appear only inside `_facade_spine.py`; zero matches in `okvis2.py`, `vins_mono.py`, `klt_ransac.py`
|
||||
|
||||
**AC-9: All AZ-332 / AZ-333 / AZ-334 existing AC tests pass unmodified**
|
||||
Given the existing `tests/unit/c1_vio/test_okvis2_strategy.py`, `test_vins_mono_strategy.py`, `test_klt_ransac_strategy.py`
|
||||
When the suites run after this task
|
||||
Then every previously-passing AC sub-test still passes; no test file outside the new `test_az528_facade_spine.py` is modified
|
||||
|
||||
**AC-10: AZ-270 layer lint still passes**
|
||||
Given the helper lives inside the c1_vio component (Layer 3) and only imports from L1 substrate
|
||||
When `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies` runs
|
||||
Then the test passes (helper is c1-internal, underscore-prefixed, not in `c1_vio/__init__.py`'s Public API surface)
|
||||
|
||||
## Constraints
|
||||
|
||||
- The helper module is c1-internal (`_facade_spine.py` underscore prefix). No other component may import it.
|
||||
- Strategy-specific values (`_feature_threshold`, `_producer_id`, `_strategy_label`, etc.) enter the mixin through `self` attributes set by each concrete strategy's `__init__` — NOT through constructor parameters on the mixin. Keeps the mixin shape stable.
|
||||
- The free functions are stateless and pure. They MUST NOT capture module-level state from any strategy module.
|
||||
- Error envelope preserved: `_frame_image` continues to raise `VioFatalError` on bad shape; no new error types introduced.
|
||||
- `_now_iso()` keeps the existing `+00:00` offset format (not `Z`) to maintain byte-equivalence with the current OKVIS2 / VINS-Mono / KLT-RANSAC behaviour. The canonical `Z`-suffix helper is `iso_ts_from_clock` in `helpers/iso_timestamps.py` (AZ-526) which serves a different purpose (FDR record timestamps from an injected `Clock`).
|
||||
- Test additions go into a new `test_az528_facade_spine.py`; AZ-332 / AZ-333 / AZ-334 test files are NOT touched.
|
||||
- The c1_vio module-level `_PRODUCER_ID` and `_STRATEGY_LABEL` Final constants remain in each strategy module (they parametrise the mixin via the `self._producer_id` / `self._strategy_label` attributes the constructor sets). Hoisting them to `_facade_spine.py` would lose per-strategy identity.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: The mixin's attribute dependencies (`_feature_threshold`, `_warm_start_max_frames`, etc.) are not declared statically — a strategy forgetting to set one breaks the mixin at runtime, not at type-check time.**
|
||||
- *Mitigation*: Document the required attributes in the `FacadeSpine` class docstring + introduce a `_facade_spine_attrs` property class-level check that runs at `__init_subclass__` time (or a `_validate_spine_attrs()` instance helper called from each concrete `__init__`). Add a unit test that explicitly verifies the three concrete strategies have all required attributes set after construction.
|
||||
- *Alternative*: a stricter Protocol-typed parameter object passed to `_classify_state` / `_emit_transition`. Rejected — it would force every call site to construct a parameter object, which is more boilerplate than the bug it prevents.
|
||||
|
||||
**Risk 2: A 3-way refactor accidentally changes the state-machine semantics for one of the strategies**
|
||||
- *Mitigation*: AC-9 (all 3 existing AC test suites pass unmodified) is the safety net — if any strategy regresses, its AC tests will fail. Plus the focused AC-5..AC-7 tests in the new file directly exercise the mixin logic in isolation.
|
||||
|
||||
**Risk 3: A future c1_vio strategy author forgets the mixin exists and adds a 4th local copy**
|
||||
- *Mitigation*: Add an AST-walk regression guard inside `test_az528_facade_spine.py` (modeled on the AZ-508 / AZ-526 / AZ-527 pattern) that asserts zero module-level `_now_iso` / `_bias_norm` / `_se3_from_4x4` / `_frame_ts_ns` / `_frame_image` definitions in any of the 3 strategy modules.
|
||||
|
||||
**Risk 4: Inheritance from a mixin couples the three strategies in a way that constrains future divergence**
|
||||
- *Risk*: A future strategy might need a different state-machine — e.g., a smoother-loop strategy with an extra "INITIALIZING" state.
|
||||
- *Mitigation*: The mixin is opt-in (free function `_emit_transition` is also exposed for strategies that want the FDR-record format but not the state machine). A future divergent strategy can either subclass `FacadeSpine` and override the relevant methods or skip the mixin entirely and use only the free functions.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: a c1-internal helper module `_facade_spine.py` exposing 5 free functions (`now_iso`, `bias_norm`, `se3_from_4x4`, `frame_ts_ns`, `frame_image`) + 1 mixin class (`FacadeSpine`) providing `_classify_state` / `_tick_lost` / `_emit_transition`.
|
||||
- **Production code that must exist**: real implementations in `_facade_spine.py`; real imports + inheritance in each of the 3 strategy modules (`okvis2.py`, `vins_mono.py`, `klt_ransac.py`); existing module-level `_PRODUCER_ID` / `_STRATEGY_LABEL` / `_lost_frame_threshold` / `_warm_start_max_frames` / `_feature_threshold` constants preserved with appropriate `self.*` attribute assignments in each strategy's `__init__`.
|
||||
- **Allowed external stubs**: none for production code. The unit test uses a minimal `FacadeSpine` subclass (test-only) + a fake `FdrClient` matching the existing AZ-273 ringbuf shape.
|
||||
- **Unacceptable substitutes**: keeping one or more local definitions "for parity"; raising a different exception type; hoisting the helper to `helpers/`; changing the existing FDR record payload shape; bundling the test-fake consolidation (F2) into this PBI.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Batch 53 — Cycle 1 Report
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Tasks**: AZ-333 (C1 VINS-Mono Strategy)
|
||||
**Verdict**: COMPLETE — PASS_WITH_WARNINGS
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `VinsMonoStrategy`, the research-only loosely-coupled
|
||||
comparative VIO that participates in the IT-12 comparative-study
|
||||
research binary only. Mirrors the AZ-332 OKVIS2 facade pattern
|
||||
deliberately so the AZ-331 factory can treat both strategies as
|
||||
drop-in substitutable. Native binding is a pybind11 skeleton compiled
|
||||
behind `BUILD_VINS_MONO=ON` (default OFF for airborne /
|
||||
operator-tooling / replay-cli); estimator wiring is the Tier-2
|
||||
follow-up.
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added (4)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/vins_mono.py` — Python
|
||||
facade, 533 lines.
|
||||
- `src/gps_denied_onboard/components/c1_vio/_native/vins_mono_binding.cpp`
|
||||
— pybind11 binding skeleton, ~275 lines.
|
||||
- `tests/unit/c1_vio/test_vins_mono_strategy.py` — AC-1..AC-10 +
|
||||
tier2 perf/honesty tests, 518 lines, 17 tests (15 pass, 2 skip).
|
||||
- `_docs/03_implementation/reviews/batch_53_review.md` — code review
|
||||
report.
|
||||
|
||||
### Modified (4)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
|
||||
`VinsMonoConfig`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
|
||||
`VinsMonoConfig` dataclass + `vins_mono` field on `C1VioConfig`;
|
||||
`__all__` updated; `KNOWN_STRATEGIES` already had `vins_mono`.
|
||||
- `cpp/vins_mono/CMakeLists.txt` — replaced AZ-263 placeholder with
|
||||
full pybind11 + linker wiring; gated by `BUILD_VINS_MONO`; ROS-strip
|
||||
flag forced OFF (Risk-1); links Eigen from
|
||||
`cpp/_third_party/eigen/` shared with OKVIS2 (Risk-2).
|
||||
- `tests/unit/c1_vio/conftest.py` — extend with `FakeVinsMonoBackend`
|
||||
+ 3 fake exception types + `fake_vins_mono_binding` fixture.
|
||||
- `tests/unit/c1_vio/test_protocol_conformance.py` — drop `vins_mono`
|
||||
from `_STRATEGIES_WITHOUT_PY_MODULE` so the existing parametrised
|
||||
factory test routes through the new strategy correctly (the
|
||||
"module missing" branch is now strictly `klt_ransac`-only until
|
||||
AZ-334 lands).
|
||||
|
||||
## AC coverage (AC-1..AC-10 + NFR-perf-document)
|
||||
|
||||
All 10 ACs mapped to passing tests (see `batch_53_review.md` Phase 2
|
||||
table). AC-9 + NFR-perf are tier2-tagged per the carry-over plan;
|
||||
they skip on macOS dev + GitHub Actions Linux runner with
|
||||
`Tier-2-only test; set GPS_DENIED_TIER=2 to run`.
|
||||
|
||||
## Test results
|
||||
|
||||
### Focused suite — `tests/unit/c1_vio/`
|
||||
|
||||
```
|
||||
72 passed, 4 skipped in 1.14s
|
||||
```
|
||||
|
||||
The 4 skips are the 2 OKVIS2 tier2 tests + the 2 new VINS-Mono tier2
|
||||
tests (`test_ac9_*` and `test_nfr_perf_*`).
|
||||
|
||||
### Adjacent regression — config / compose-root / inference shim
|
||||
|
||||
```
|
||||
22 passed in 2.87s
|
||||
```
|
||||
|
||||
(`test_az269_config_loader.py`, `test_az270_compose_root.py`,
|
||||
`test_az507_inference_errors_shim.py` — all green; the new
|
||||
`VinsMonoConfig` registration did not break the schema-loader or
|
||||
compose-root layered-import guards.)
|
||||
|
||||
### Full suite
|
||||
|
||||
```
|
||||
1 failed, 1788 passed, 82 skipped in 79.15s
|
||||
```
|
||||
|
||||
The single failure is a pre-existing environment-dependent perf flake:
|
||||
|
||||
- `tests/unit/c12_operator_orchestrator/test_cli_console_script.py::test_cold_start_under_500ms_p99`
|
||||
— measures CLI cold-start wall-clock; reports `worst-after-trim
|
||||
997.4 ms` vs the `≤ 500 ms` NFR target. macOS dev runner cannot
|
||||
hit this on a cold spawn; the test is environment-bound to Linux
|
||||
CI hardware. No touchpoint to c1_vio. Reported to the user.
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
- **Mirroring OKVIS2 1:1**: the constructor / state machine / except
|
||||
ladder in `vins_mono.py` is a deliberate copy of `okvis2.py`. The
|
||||
AZ-331 factory + IT-12 harness require shape-compatibility; doing
|
||||
the consolidation now (before AZ-334's KltRansac shape is visible)
|
||||
would over-fit. Tracked as Low-severity finding F1 in the review;
|
||||
scheduled for the post-AZ-334 hygiene PBI (precedent: AZ-340 →
|
||||
AZ-527 for c2_vpr).
|
||||
- **Test fake mirroring**: same logic for `FakeVinsMonoBackend` vs
|
||||
`FakeOkvis2Backend`. The shared `ScriptedOutput` dataclass and
|
||||
`_make_default_payload` helper ARE reused (the productive cut);
|
||||
the backend class duplication is the deferred-consolidation slice.
|
||||
- **CMakeLists ROS-strip + Eigen pin**: explicit Risk-1 mitigation
|
||||
(`VINS_MONO_USE_ROS=OFF` forced), Risk-2 mitigation (Eigen pin
|
||||
from the shared `cpp/_third_party/eigen/`), Risk-3 mitigation (the
|
||||
binding never builds when `BUILD_VINS_MONO=OFF`, so deployment
|
||||
binaries stay clean).
|
||||
- **Skeleton binding throws on first frame**: matches OKVIS2 — a
|
||||
research binary that loads the `.so` before tier-2 wires the real
|
||||
`vins_estimator::Estimator` cannot silently emit misleading poses
|
||||
(`VinsMonoFatalException` from `_drive_estimator`).
|
||||
- **NFR-perf is recorded, not bounded**: per task spec, VINS-Mono is
|
||||
exempt from C1-PT-01's 80 ms p95. The tier2 perf test asserts only
|
||||
that `process_frame` completes 200× without deadlock and that p95
|
||||
is below a loose 5-second sanity ceiling. The Step 9 / E-BBT
|
||||
comparative-study report consumes the actual p50/p95 number.
|
||||
|
||||
## Out of scope / deferred
|
||||
|
||||
- Real `vins_estimator::Estimator` wiring inside the binding — Tier-2
|
||||
follow-up; not required for the AZ-333 facade ACs.
|
||||
- KltRansac strategy (AZ-334) — separate batch.
|
||||
- Warm-start hint persistence (AZ-335) — separate batch, depends on
|
||||
AZ-333 + AZ-334.
|
||||
- Strategy-facade consolidation hygiene PBI — informally tracked,
|
||||
formal PBI to be raised by the next cumulative review (batches
|
||||
52-54) once all three c1_vio strategies are landed.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Batch 54 — Cycle 1 Report
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Tasks**: AZ-334 (C1 KLT/RANSAC Strategy)
|
||||
**Verdict**: COMPLETE — PASS_WITH_WARNINGS
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `KltRansacStrategy`, the ADR-002 engine-rule mandatory
|
||||
simple-baseline VIO for C1. Pure-Python facade over OpenCV's
|
||||
`cv2.goodFeaturesToTrack` / `cv2.calcOpticalFlowPyrLK` /
|
||||
`cv2.findEssentialMat` / `cv2.recoverPose` pipeline — no C++/pybind11
|
||||
binding by design so a Tier-0 workstation can run the strategy with
|
||||
`pip install opencv-python` and the AZ-331 factory's
|
||||
`BUILD_KLT_RANSAC=ON` gate. Mirrors the AZ-332 OKVIS2 + AZ-333
|
||||
VINS-Mono facade pattern on the orchestration spine so the AZ-331
|
||||
factory + IT-12 comparative harness treat all three strategies as
|
||||
drop-in substitutable.
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added (3)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` —
|
||||
Python facade, ~770 lines.
|
||||
- `tests/unit/c1_vio/test_klt_ransac_strategy.py` — AC-1..AC-11 +
|
||||
NFR-perf + KltRansacConfig validation tests, ~990 lines, 25 tests
|
||||
(23 pass + 2 tier-2 skipped on dev/CI runners).
|
||||
- `_docs/03_implementation/reviews/batch_54_review.md` — code review
|
||||
report.
|
||||
|
||||
### Modified (4)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
|
||||
`KltRansacConfig` dataclass + `klt_ransac` field on `C1VioConfig`;
|
||||
`__all__` updated. (Pre-existing `KNOWN_STRATEGIES` already had
|
||||
`klt_ransac` from AZ-331.)
|
||||
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
|
||||
`KltRansacConfig`.
|
||||
- `cpp/klt_ransac/CMakeLists.txt` — replace AZ-263 placeholder with
|
||||
a deliberate "pure-Python; no native target" STATUS message so
|
||||
the build graph stays symmetric with `cpp/okvis2/` and
|
||||
`cpp/vins_mono/` while the `BUILD_KLT_RANSAC=ON` flag only gates
|
||||
the Python module import at the AZ-331 composition-root factory.
|
||||
- `tests/unit/c1_vio/test_protocol_conformance.py` — introduce the
|
||||
`_STRATEGIES_WITHOUT_NATIVE_BINDING` category and route
|
||||
`klt_ransac` through it inside
|
||||
`test_ac5_build_vio_strategy_flag_on_but_module_missing` so the
|
||||
parametrised factory test correctly handles the pure-Python
|
||||
shape (no native binding to fail on; the construction should
|
||||
succeed and return a `VioStrategy` instance instead).
|
||||
|
||||
## AC coverage (AC-1..AC-11 + NFR-perf)
|
||||
|
||||
All 11 ACs + NFR-perf mapped to passing tests (see
|
||||
`batch_54_review.md` Phase 2 table). AC-9 + NFR-perf are
|
||||
tier-2-tagged per the task spec; they skip on macOS dev + GitHub
|
||||
Actions Linux runner with `Tier-2-only test; set GPS_DENIED_TIER=2
|
||||
to run`. AC-3 / AC-6 / AC-9 / AC-10 / AC-11 / NFR-perf monkeypatch
|
||||
`cv2.findEssentialMat` + `cv2.recoverPose` +
|
||||
`RansacFilter.filter_correspondences` to deterministic values so
|
||||
the unit suite exercises the FACADE's state machine without
|
||||
depending on real OpenCV geometry on synthetic correspondences;
|
||||
real-geometry validation lives in C1-IT-12 (Jetson Tier-2 fixture).
|
||||
|
||||
## Test results
|
||||
|
||||
### Focused suite — `tests/unit/c1_vio/`
|
||||
|
||||
```
|
||||
95 passed, 6 skipped in 1.67s
|
||||
```
|
||||
|
||||
The 6 skips are the 2 OKVIS2 tier-2 tests + the 2 VINS-Mono tier-2
|
||||
tests + the 2 new KLT/RANSAC tier-2 tests (`test_ac9_*` and
|
||||
`test_nfr_perf_*`).
|
||||
|
||||
### Adjacent regression — config / compose-root
|
||||
|
||||
```
|
||||
17 passed in 1.96s
|
||||
```
|
||||
|
||||
(`test_az269_config_loader.py`, `test_az270_compose_root.py` — all
|
||||
green; the new `KltRansacConfig` registration did not break the
|
||||
schema-loader or compose-root layered-import guards.)
|
||||
|
||||
### Tier-2 verification
|
||||
|
||||
```
|
||||
GPS_DENIED_TIER=2 pytest tests/unit/c1_vio/test_klt_ransac_strategy.py
|
||||
→ 25 passed in 0.43s
|
||||
```
|
||||
|
||||
All 25 tests pass under the tier-2 gate (including AC-9 honest-
|
||||
covariance monotonicity over 48 synthetic frames + NFR-perf p95
|
||||
record).
|
||||
|
||||
### Full suite
|
||||
|
||||
Deferred per the implement skill's Test-Run Cadence — the full
|
||||
unit-suite gate runs exactly once at Step 16 (end of implementation
|
||||
phase), not per-batch. Focused tests + cumulative review (every K
|
||||
batches) catch cross-batch regressions before then.
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
- **No native binding by design**: KLT/RANSAC is pure Python over
|
||||
OpenCV's Python bindings. The `cpp/klt_ransac/CMakeLists.txt`
|
||||
placeholder is preserved (with an explanatory STATUS message) for
|
||||
build-graph symmetry with `cpp/okvis2/` and `cpp/vins_mono/`; the
|
||||
`BUILD_KLT_RANSAC=ON` flag only gates the Python module import at
|
||||
the AZ-331 composition-root factory.
|
||||
- **Constructor shape matches factory**: `KltRansacStrategy(config,
|
||||
*, fdr_client, clock=None)` mirrors `Okvis2Strategy` +
|
||||
`VinsMonoStrategy` so the AZ-331 factory invokes all three via the
|
||||
same call shape. The task spec's illustrative constructor
|
||||
(with explicit injection of `CameraCalibration` / `ImuPreintegrator`
|
||||
/ `RansacFilter` / `Logger`) was deliberately not adopted because
|
||||
it would diverge from the existing factory contract; the same
|
||||
dependencies are resolved internally instead — `RansacFilter` is
|
||||
static (AZ-282 stateless helper) and `ImuPreintegrator` is
|
||||
constructed lazily on the first `process_frame` call (it needs
|
||||
the per-call `CameraCalibration` which is not available at
|
||||
construction time).
|
||||
- **Honest covariance, AC-9 compliant**: per-frame covariance =
|
||||
`np.eye(6) * (sigma_sq + inlier_penalty) / max(inlier_count - 5, 1)`
|
||||
where `sigma_sq = median_residual_px**2` and `inlier_penalty =
|
||||
threshold_px / max(inlier_count, 1)`. No client-side floor or
|
||||
smoother; the formula grows monotonically as inliers drop or
|
||||
residuals scatter. Tier-2 test walks 48 scripted-inlier frames
|
||||
through the DEGRADED window and verifies monotonicity.
|
||||
- **Camera agnostic, AC-11 enforced**: no `adti20` / `adti26`
|
||||
literals in executable source (docstring mentions are excluded
|
||||
from the CI grep via AST-aware stripping in the test). The
|
||||
per-call `CameraCalibration` argument carries intrinsics; the
|
||||
same code path produces sensible `VioOutput` for two distinct
|
||||
calibrations (different f, cx, cy).
|
||||
- **State machine mirrors OKVIS2 / VINS-Mono**: `INIT` until
|
||||
`warm_start_max_frames` is exhausted, then `TRACKING` if inlier
|
||||
count ≥ `min_features_for_pose`, else `DEGRADED`; sustained
|
||||
pose-recovery failure for `lost_frame_threshold` consecutive
|
||||
frames raises `VioFatalError` with state == `LOST`. Exactly one
|
||||
`vio.health` FDR record per transition (AC-10).
|
||||
- **Error envelope closed**: every OpenCV `cv2.error` is caught at
|
||||
each call site and rewrapped into `VioFatalError` with
|
||||
`__cause__` chaining (AC-4). `RansacFilterError` +
|
||||
`ImuPreintegrationError` are also caught and rewrapped.
|
||||
|
||||
## Out of scope / deferred
|
||||
|
||||
- Real geometry validation against Derkachi fixtures — Tier-2
|
||||
follow-up; C1-IT-12 binds KLT/RANSAC alongside OKVIS2.
|
||||
- Warm-start hint persistence (AZ-335) — separate batch.
|
||||
- Strategy-facade consolidation hygiene PBI — now formally in scope
|
||||
for the cumulative review covering batches 52-54 (next trigger).
|
||||
All three strategy shapes (OKVIS2, VINS-Mono, KLT/RANSAC) are
|
||||
now visible so the right factoring (template-method base class
|
||||
for the orchestration spine + per-strategy geometry hook) can be
|
||||
scoped without over-fitting.
|
||||
|
||||
## Honest-covariance numeric envelope (AC-9 evidence)
|
||||
|
||||
Tier-2 test walks the following inlier-count sequence through the
|
||||
DEGRADED gate (`min_features_for_pose=50`):
|
||||
|
||||
| Frame range | Inlier count | State | Cov Frobenius (approx) |
|
||||
|-------------|--------------|----------|------------------------|
|
||||
| 1 | first-frame | INIT | 24.49 (10·√6) |
|
||||
| 2..29 | 80 | TRACKING | ≈ 0.0056 |
|
||||
| 30 | 40 | DEGRADED | ≈ 0.013 |
|
||||
| 31..43 | 35..12 | DEGRADED | strictly increasing |
|
||||
| 44..49 | 10 | DEGRADED | ≈ 0.127 |
|
||||
|
||||
The Frobenius norm grows monotonically across the entire DEGRADED
|
||||
window — the AC-9 honest-covariance invariant is upheld by the
|
||||
formula's structure, not by a floor clamp.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Batch 55 — Cycle 1 Report
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Tasks**: AZ-528 (Hygiene — c1_vio strategy facade orchestration-spine consolidation)
|
||||
**Verdict**: COMPLETE — PASS
|
||||
|
||||
## Summary
|
||||
|
||||
Consolidated the 3-way orchestration-spine duplication across the
|
||||
c1_vio `VioStrategy` modules (`okvis2.py`, `vins_mono.py`,
|
||||
`klt_ransac.py`) into a single c1-internal helper module
|
||||
`_facade_spine.py`. Closes cumulative review batches 52-54 Finding
|
||||
F1 (Medium / Maintainability). Mirrors the AZ-527 precedent for the
|
||||
c2_vpr-side `_assert_engine_output_dim` consolidation. No behaviour
|
||||
change — every existing AZ-332 / AZ-333 / AZ-334 AC test passes
|
||||
unmodified.
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added (2)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/_facade_spine.py` — new
|
||||
c1-internal helper, 225 lines. Exports 5 free functions (`now_iso`,
|
||||
`bias_norm`, `se3_from_4x4`, `frame_ts_ns`, `frame_image`) and the
|
||||
`FacadeSpine` mixin class (`_classify_state`, `_tick_lost`,
|
||||
`_emit_transition`).
|
||||
- `tests/unit/c1_vio/test_az528_facade_spine.py` — new test file, 19
|
||||
tests covering AC-1..AC-8 + a Risk-1 mitigation test (each
|
||||
strategy's `__init__` statically asserts that every spine-required
|
||||
attribute is assigned). All pass.
|
||||
|
||||
### Modified (3)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/okvis2.py` — inherit from
|
||||
`FacadeSpine`; import the 5 free functions; delete the 5 local
|
||||
module-level definitions, 3 instance methods, and the unused
|
||||
`_BIAS_NORM_FLOOR` constant. Set the 3 spine-required attributes
|
||||
(`_feature_threshold`, `_producer_id`, `_strategy_label`) in
|
||||
`__init__`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/vins_mono.py` — same
|
||||
pattern as `okvis2.py`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` — same
|
||||
pattern; spine threshold reads `self._cfg.min_features_for_pose`
|
||||
(the KLT-side equivalent of OKVIS2's `degraded_feature_threshold`).
|
||||
Deferred-consolidation comments referencing this PBI removed.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/unit/c1_vio/test_az528_facade_spine.py` — 19 new tests, all
|
||||
pass.
|
||||
- `tests/unit/c1_vio/` (full focused suite) — 120 collected, 114
|
||||
pass + 6 tier-2 skipped (unchanged from pre-AZ-528 state).
|
||||
- Adjacent regression suite (`test_az270_compose_root`,
|
||||
`test_az272_fdr_record_schema`, `test_az273_fdr_client_ringbuf`,
|
||||
`test_ac1_scaffold_layout`, `tests/unit/c1_vio/`) — 237 pass + 6
|
||||
skipped.
|
||||
|
||||
## AC traceability
|
||||
|
||||
| AC | Status | Notes |
|
||||
|-------|--------|---------------------------------------------------------|
|
||||
| AC-1 | ✓ | Helper module exposes documented surface. |
|
||||
| AC-2 | ✓ | `now_iso()` returns aware UTC `+00:00` ISO-8601. |
|
||||
| AC-3 | ✓ | `bias_norm` matches L2 formula (accel + gyro). |
|
||||
| AC-4 | ✓ | `se3_from_4x4` builds `gtsam.Pose3` identity correctly. |
|
||||
| AC-5 | ✓ | `_classify_state` INIT during warmup, T/D thresholds. |
|
||||
| AC-6 | ✓ | `_tick_lost` demotes T→D first call, escalates to LOST. |
|
||||
| AC-7 | ✓ | `_emit_transition` exactly one FDR record per change. |
|
||||
| AC-8 | ✓ | AST guard: 0 module-level free funcs in 3 strategies. |
|
||||
| AC-9 | ✓ | All AZ-332 / AZ-333 / AZ-334 AC tests pass unmodified. |
|
||||
| AC-10 | ✓ | AZ-270 layer-lint regression still passes. |
|
||||
|
||||
## Code review
|
||||
|
||||
See `_docs/03_implementation/reviews/batch_55_review.md` — verdict
|
||||
PASS, no findings.
|
||||
|
||||
## Outcomes & lessons
|
||||
|
||||
- Pattern of an "AST regression guard against duplicated free
|
||||
functions" continues to work well (AZ-508 / AZ-526 / AZ-527 /
|
||||
AZ-528 all use it). It catches re-introduction by future authors
|
||||
who don't know the consolidated home exists.
|
||||
- Mixin-via-instance-attributes (rather than constructor parameters)
|
||||
keeps strategy `__init__` signatures stable — the AZ-331 factory
|
||||
signature `(config, *, fdr_client)` is preserved across all three
|
||||
strategies post-refactor.
|
||||
- A 3-point hygiene PBI was the right granularity: 1 new module,
|
||||
3 small edits to existing strategies, 1 test file with AST + import
|
||||
regression guards. Same shape and complexity as AZ-527.
|
||||
|
||||
## Outstanding
|
||||
|
||||
None for AZ-528. F2 from cumulative review batches 52-54 (test fake
|
||||
+ `_patch_pose_recovery` helper consolidation) remains deferred —
|
||||
sits at a different abstraction layer and will ride a later hygiene
|
||||
pass.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Batch 56 — Cycle 1 Report
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Tasks**: AZ-335 (C1 Warm-Start + F8 Reboot Recovery)
|
||||
**Verdict**: COMPLETE — PASS_WITH_WARNINGS
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the cross-strategy warm-start hint persistence layer, the
|
||||
F2 takeoff (FC EKF) and F8 reboot (disk) prime hooks, and the AC-5.3
|
||||
"no fake confidence" covariance enforcement at the runtime composition
|
||||
layer. The persistence layer is c1-internal
|
||||
(`components/c1_vio/warm_start_store.py`); the cross-strategy wiring
|
||||
(wrapper + prime hooks) lives at the composition root
|
||||
(`runtime_root/warm_start_wiring.py`) so any concrete `VioStrategy`
|
||||
gains warm-start behaviour without per-strategy edits. AC-5.3 is
|
||||
enforced via a wrapper-owned post-reset covariance inflation +
|
||||
baseline floor — not by mutating any strategy. Default ships with the
|
||||
`JsonSidecarWarmStartHintStore` (atomic JSON + SHA-256 sidecar via
|
||||
AZ-280); a future Redis-backed store can plug in via the same
|
||||
`WarmStartHintStore` Protocol without touching the wiring.
|
||||
|
||||
Closes the AZ-335 dependency chain: AZ-331 / AZ-332 / AZ-333 / AZ-334
|
||||
(strategies) + AZ-263 / AZ-269 / AZ-266 / AZ-270 (bootstrap +
|
||||
config + log + compose lint) + AZ-280 (sha256 sidecar) + AZ-272 (FDR
|
||||
schema). Runs immediately after AZ-528 (batch 55) — no other c1_vio
|
||||
work was blocked behind AZ-335.
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added (3)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/warm_start_store.py` — 440
|
||||
lines. Exports `HINT_FILENAME`, `HINT_SCHEMA_VERSION`,
|
||||
`LoadedWarmStartHint` dataclass, `WarmStartHintStore` Protocol,
|
||||
`WarmStartFcSource` Protocol (consumer-side cut over C8 FcAdapter
|
||||
per AZ-507), and the default `JsonSidecarWarmStartHintStore` impl.
|
||||
JSON schema v1: `version`, `calibration_id` (Risk-2 mitigation),
|
||||
`pre_reboot_covariance_norm` (AC-8 floor), `pose` block (4×4 matrix
|
||||
+ velocity + bias + ns timestamp).
|
||||
- `src/gps_denied_onboard/runtime_root/warm_start_wiring.py` — 563
|
||||
lines. Exports `WARM_START_PRODUCER_ID`, `WarmStartWiredStrategy`
|
||||
(the wrapper that adds AC-6 throttled save + AC-7 inflation + AC-8
|
||||
floor on top of any inner `VioStrategy`),
|
||||
`prime_warm_start_from_disk` (F8 hook), and
|
||||
`prime_warm_start_from_fc` (F2 hook). Single point of FDR record
|
||||
emission via `_emit_prime_fdr` and single point of INFO/WARN log
|
||||
emission via `_emit_prime_log`.
|
||||
- `tests/unit/c1_vio/test_az335_warm_start.py` — 34 unit tests
|
||||
covering all 10 ACs + 3 NFRs. Local fakes for `VioStrategy` and
|
||||
`WarmStartFcSource`; real `Sha256Sidecar` on `tmp_path` for the
|
||||
store tests so AC-1 / AC-2 / AC-10 atomicity contracts are
|
||||
exercised against the production helper.
|
||||
|
||||
### Modified (3)
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/config.py` — added
|
||||
`warm_start_store_dir` (default `/var/lib/gps_denied_onboard/warm_start/`),
|
||||
`warm_start_save_period_frames` (default 5),
|
||||
`post_reset_covariance_inflation_factor` (default 2.0). Each new
|
||||
field has a `__post_init__` validation matching the existing
|
||||
pattern.
|
||||
- `src/gps_denied_onboard/fdr_client/records.py` — registered the new
|
||||
FDR kind `vio.warm_start` in `KNOWN_PAYLOAD_KEYS` with the
|
||||
frozen schema {`source`, `strategy_label`, `bias_norm`,
|
||||
`staleness_ns`, `pre_reboot_covariance_norm`}.
|
||||
- `tests/unit/test_az272_fdr_record_schema.py` — added the per-kind
|
||||
fixture branch for `vio.warm_start` so the AC-1 round-trip suite
|
||||
stays exhaustive over `KNOWN_KIND`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/unit/c1_vio/test_az335_warm_start.py` — 34 new tests, all
|
||||
pass (4.01 s).
|
||||
- Adjacent regression sweep (`tests/unit/c1_vio/`,
|
||||
`tests/unit/c13_fdr/`, `tests/unit/composition_root/`,
|
||||
`test_az272_fdr_record_schema`, `test_az269_config_loader`,
|
||||
`test_az270_compose_root`, `test_az273_fdr_client_ringbuf`,
|
||||
`test_az266_logging_schema`, `test_ac1_scaffold_layout`) — 356
|
||||
pass + 6 tier-2 skipped (unchanged from pre-AZ-335 state).
|
||||
|
||||
## AC traceability
|
||||
|
||||
| AC | Status | Test |
|
||||
|-------|--------|-------------------------------------------------------------------|
|
||||
| AC-1 | ✓ | `TestStoreAc1RoundTrip` (3 tests; deep-equal + file presence) |
|
||||
| AC-2 | ✓ | `TestStoreAc2Corrupted` (3 tests; sha mismatch + bad envelope) |
|
||||
| AC-3 | ✓ | `TestWiringAc3ColdStart::test_cold_start_does_not_invoke_reset` |
|
||||
| AC-4 | ✓ | `TestWiringAc4F8Reboot::test_f8_reboot_loads_hint_calls_reset_emits_fdr` |
|
||||
| AC-5 | ✓ | `TestWiringAc5F2Takeoff::test_f2_takeoff_fetches_fc_calls_reset_persists` |
|
||||
| AC-6 | ✓ | `TestWiringAc6PerFrameSave` (2 tests; period=5 + period=1) |
|
||||
| AC-7 | ✓ | `TestWiringAc7PostResetInflation` (2 tests; with/without reset) |
|
||||
| AC-8 | ✓ | `TestWiringAc8CovarianceFloor` (2 tests; floor active + dormant) |
|
||||
| AC-9 | ✓ | `TestStoreAc9Clear` (3 tests; remove + log + idempotent) |
|
||||
| AC-10 | ✓ | `TestStoreAc10Atomicity::test_kill_mid_save_leaves_prior_hint_loadable` |
|
||||
| NFR-perf-save | ✓ | `TestStoreNfrPerf::test_nfr_perf_save_p99_under_50ms` |
|
||||
| NFR-perf-load | ✓ | `TestStoreNfrPerf::test_nfr_perf_load_p99_under_20ms` |
|
||||
| NFR-no-crash | ✓ | `TestWiringNfrNoCrash` (4 tests; FC raise/None + save fail + reset fail) |
|
||||
| Risk-2 (calib) | ✓ | `TestStoreAc3CalibrationMismatch::test_calibration_mismatch_returns_none_with_specific_warn` |
|
||||
|
||||
## Code review
|
||||
|
||||
See `_docs/03_implementation/reviews/batch_56_review.md` — verdict
|
||||
**PASS_WITH_WARNINGS**, 1 Medium + 3 Low findings, all
|
||||
informational / documentation-tightening:
|
||||
|
||||
- F1 (Style, Low): AC-3 spec text shorthand vs source-suffixed log
|
||||
kind — recommend updating spec phrasing in cycle 2.
|
||||
- F2 (Maintainability, Medium): per-frame save uses strategy-frame
|
||||
pose as `body_T_world`; semantically defensible because the
|
||||
strategy's "internal frame" persists across F8 reload via the
|
||||
saved pose; recommend an inline 3-line comment explaining the
|
||||
design choice.
|
||||
- F3 (Spec-Gap, Low): NFR perf tests are dev-hardware smoke; full
|
||||
Tier-2 NVMe perf gate is owned by C1-PT-01 (deferred to E-BBT).
|
||||
- F4 (Architecture, Low): `runtime_root/warm_start_wiring.py`
|
||||
imports c1-internal `_facade_spine` for shared FDR conventions;
|
||||
allowed by module-layout §6, but noted for a possible future
|
||||
promotion of `bias_norm` to `helpers/imu_bias.py`.
|
||||
|
||||
## Outcomes & lessons
|
||||
|
||||
- The Protocol-cut-at-consumer pattern (defining `WarmStartFcSource`
|
||||
inside `c1_vio/warm_start_store.py` instead of importing the
|
||||
concrete C8 `FcAdapter`) is the right shape for AZ-507 compliance.
|
||||
The composition root will wire a thin adapter from C8's actual
|
||||
`FcAdapter` to this Protocol. The AZ-335 wiring tests inject a
|
||||
fake matching the surface directly — no C8 dependency in the test.
|
||||
- Wrapping (rather than per-strategy mixing) for cross-strategy
|
||||
concerns scales: AC-7 inflation + AC-8 floor + AC-6 throttled save
|
||||
all live in one 240-line wrapper class with one inner
|
||||
`VioStrategy` field. The three strategies (OKVIS2 / VINS-Mono /
|
||||
KLT-RANSAC) needed zero edits.
|
||||
- AC-7 and AC-8 stack cleanly: inflation is applied first, then if
|
||||
the inflated norm is below the AC-8 floor it is scaled up to the
|
||||
floor. Both operations preserve SPD because they're positive
|
||||
scalar multiplications. No matrix re-decomposition required.
|
||||
- The AC-NFR-no-crash policy (catch + log + return False; never
|
||||
propagate) is enforced at every prime hook seam: FC source raise,
|
||||
FC source returns None, store.save raises, inner.reset raises.
|
||||
Each path emits a distinct log `kind` so post-mortem can
|
||||
partition the failure mode.
|
||||
|
||||
## Outstanding
|
||||
|
||||
- F1 / F2 / F3 / F4 from this batch's review — non-blocking;
|
||||
recommend folding into a future hygiene PBI alongside any AZ-345+
|
||||
c3 work that touches the same `vio.warm_start` FDR namespace.
|
||||
- The composition root's `compose_*` binaries do NOT yet wire a
|
||||
`WarmStartWiredStrategy` over the `vio_factory` output. The wiring
|
||||
is in place; the actual call site (`runtime_root/runtime.py` or
|
||||
the per-binary compose script) needs to construct the
|
||||
`WarmStartWiredStrategy` + `JsonSidecarWarmStartHintStore` and
|
||||
call the F8 prime hook before the first `process_frame`. This is
|
||||
out of scope for AZ-335 (the spec only delivers the wiring
|
||||
module, not the per-binary integration); the integration belongs
|
||||
to the next-cycle compose-root task that adds the F2/F8 hook
|
||||
invocations alongside the existing strategy build.
|
||||
|
||||
## Next batch
|
||||
|
||||
AZ-345 (C3 DISK + LightGlue Primary Matcher, 5 points) is the next
|
||||
unblocked product PBI per `_dependencies_table.md`. All its
|
||||
dependencies (AZ-263, AZ-269, AZ-278, AZ-282, AZ-298, AZ-299,
|
||||
AZ-303, AZ-281, AZ-321, AZ-266, AZ-272, AZ-344) are complete.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Cumulative Code Review — Batches 52-54 (Cycle 1)
|
||||
|
||||
**Date**: 2026-05-14
|
||||
**Range**: batches 52 (AZ-527 — c2_vpr `_assert_engine_output_dim` consolidation), 53 (AZ-333 — C1 VINS-Mono Strategy), 54 (AZ-334 — C1 KLT/RANSAC Strategy)
|
||||
**Compared against**: previous cumulative review batches 49-51
|
||||
**Verdict**: **PASS_WITH_WARNINGS**
|
||||
|
||||
## Scope
|
||||
|
||||
32 files changed across batches 52-54 (`git diff --stat f6a180e..HEAD`): 16 src/, 5 test/, 1 native binding (cpp), 2 cmake, 6 docs (3 batch reports + 3 review reports), 2 indirect (package init + deps table + state file). Cumulative review focuses on cross-batch concerns that per-batch reviews cannot catch: duplicate symbols introduced across different batches, architecture drift across the trio, contract drift between producer/consumer batches, and follow-through on prior cumulative findings.
|
||||
|
||||
The trio is the strategic centre of this window: AZ-333 + AZ-334 completed the c1_vio `VioStrategy` triplet (Okvis2 + VinsMono + KltRansac), making the orchestration-spine duplication finding (deferred since B53) actionable for the first time. AZ-527 closed the parallel c2_vpr-side finding.
|
||||
|
||||
## Carry-over closure from cumulative review 49-51
|
||||
|
||||
| Prior finding | Status | Notes |
|
||||
|---------------|--------|-------|
|
||||
| F1 (Medium) — `_assert_engine_output_dim` duplicated 7-way across c2_vpr strategies | **CLOSED by AZ-527 / Batch 52** | All 7 strategy modules (`ultra_vpr.py`, `net_vlad.py`, `mega_loc.py`, `mix_vpr.py`, `sela_vpr.py`, `eigen_places.py`, `salad.py`) now import `assert_engine_output_dim` from `c2_vpr/_engine_dim_assertion.py`. Regression guard: `tests/unit/c2_vpr/test_az527_engine_dim_assertion.py::test_ac4_no_stray_engine_dim_assertion_definitions_outside_helper` (AST walk) + `::test_ac4_seven_strategy_modules_import_the_helper` (import grep). Helper stays c2-internal (underscore-prefixed module, NOT in `c2_vpr/__init__.py`'s Public API). |
|
||||
| F2 (Low) — AC-10 spec wording drift (`ConfigurationError` vs `StrategyNotAvailableError`/`ConfigError`) across AZ-337/338/339/340 specs | **OPEN — carry forward** | Documentation-only drift in `_docs/02_tasks/done/AZ-337..AZ-340_*.md` § AC-10. No code defect. Sized at <1 point; can ride along with any future c2_vpr spec touch. Not escalated. |
|
||||
|
||||
## Findings (this window)
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| F1 | Medium | Maintainability | `c1_vio/{okvis2,vins_mono,klt_ransac}.py` | c1_vio strategy facade orchestration spine duplicated 3-way — **AZ-528 created** |
|
||||
| F2 | Low | Maintainability | `tests/unit/c1_vio/{conftest.py, test_klt_ransac_strategy.py}` | Test fake / `_patch_pose_recovery` patching helper not yet shared across the c1_vio strategy trio — deferred to a later hygiene pass |
|
||||
|
||||
## Finding Details
|
||||
|
||||
### F1: c1_vio strategy facade orchestration spine duplicated 3-way (Medium / Maintainability) — AZ-528 created
|
||||
|
||||
- **Locations**:
|
||||
- `src/gps_denied_onboard/components/c1_vio/okvis2.py` (AZ-332)
|
||||
- `src/gps_denied_onboard/components/c1_vio/vins_mono.py` (AZ-333, B53)
|
||||
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` (AZ-334, B54)
|
||||
|
||||
- **Description**: The following module-level helpers are byte-identical across all three strategy modules:
|
||||
|
||||
- `_now_iso() -> str` — ISO-8601 UTC timestamp for FDR record `ts`.
|
||||
- `_bias_norm(bias: ImuBias) -> float` — L2 norm of the concatenated 6-vector `(accel ‖ gyro)`.
|
||||
- `_se3_from_4x4(matrix) -> gtsam.Pose3` — lazy gtsam import + np-array coerce.
|
||||
|
||||
The following instance methods are byte-identical modulo strategy-local config-knob references and the strategy-label / producer-ID constants:
|
||||
|
||||
- `_classify_state(self, fq) -> VioState` — INIT/TRACKING/DEGRADED gate; differs only in the threshold attribute (`self._okvis2_cfg.degraded_feature_threshold` vs `self._cfg.min_features_for_pose`).
|
||||
- `_tick_lost(self, frame_id)` — DEGRADED/LOST transition counter; byte-identical.
|
||||
- `_emit_transition(self, new_state, frame_id)` — FDR `vio.health` record emit; differs only in the module-level `_PRODUCER_ID` and `_STRATEGY_LABEL` constants captured at emit time.
|
||||
- `_frame_ts_ns(frame)` + `_frame_image(frame)` — OKVIS2 + VINS-Mono only; KLT/RANSAC uses inline numpy access (equivalent shape; remains exempt from the consolidation since its geometry pipeline owns image coercion).
|
||||
|
||||
The geometry-specific pipeline (OKVIS2 cascade init + native binding driver, VINS-Mono loosely-coupled estimator driver, KLT/RANSAC seed/track/recoverPose) IS unique to each strategy and lives in its own module. That boundary is correct — the hygiene PBI does NOT touch the geometry pipelines.
|
||||
|
||||
- **Action taken**: Hygiene PBI **AZ-528** formally opened in Jira (`Hygiene — consolidate c1_vio strategy facade orchestration spine`, 3 points, Epic AZ-254 E-C1). Scope: add `src/gps_denied_onboard/components/c1_vio/_facade_spine.py` exposing the 3 free functions + a `FacadeSpine` mixin parametrised by a strategy-local feature-threshold hook; replace the duplicated definitions in the 3 strategy modules with imports; add a focused unit test + AST-walk regression guard + import-grep regression guard; re-run existing AZ-332 / AZ-333 / AZ-334 AC tests unmodified. Link: <https://denyspopov.atlassian.net/browse/AZ-528>.
|
||||
|
||||
- **Why escalated from Low (B53 + B54 per-batch) to Medium**: at 3-way the per-edit risk of one copy drifting from the others (different error-message wording, different state-machine threshold semantics, different FDR payload field order) is non-trivial — the same logic the cumulative B46-48 review used to escalate `_iso_ts_from_clock`. Every future c1_vio strategy would add a 4th copy unless we close the pattern now. The right factoring (template-method spine + per-strategy geometry hook) is now visible because all three strategy shapes exist; doing the consolidation before AZ-334 landed would have over-fit the spine to the OKVIS2 + VINS-Mono pair.
|
||||
|
||||
- **Why Medium, not High**: the duplication is structurally bounded — three strategy files, no consumer code drifting around it. The spine is byte-identical today; there is no active drift. High would be appropriate only if one copy had already drifted from the others (it has not — the per-batch reviews verified parity at each land).
|
||||
|
||||
### F2: c1_vio test fakes / `_patch_pose_recovery` not yet shared (Low / Maintainability)
|
||||
|
||||
- **Locations**:
|
||||
- `tests/unit/c1_vio/conftest.py` (`FakeOkvis2Backend` + `FakeVinsMonoBackend`)
|
||||
- `tests/unit/c1_vio/test_klt_ransac_strategy.py` (`_patch_pose_recovery`)
|
||||
|
||||
- **Description**: `FakeOkvis2Backend` and `FakeVinsMonoBackend` are near-copies with renamed exception types and renamed scripted-output payloads. The shared `ScriptedOutput` dataclass + `_make_default_payload` helper ARE already extracted to `conftest.py` (the productive cut from B53). The KLT/RANSAC test file uses a different abstraction (`_patch_pose_recovery` monkey-patches real OpenCV bindings rather than substituting a fake backend) because KLT/RANSAC has no native binding to fake. A unified "scripted success per strategy" fixture surface is plausible but lives at a different abstraction layer than F1.
|
||||
|
||||
- **Suggestion**: defer to a later hygiene pass — explicitly NOT bundled into AZ-528. The facade-spine consolidation should land cleanly first; the test-fixture refactor can ride a future c1_vio touch (e.g. when AZ-335 / warm-start hint persistence lands). Sized at 1-2 points. Recording here so the next cumulative review sees it carried over rather than re-discovered.
|
||||
|
||||
- **Defer rationale**: Low severity, no active drift, no test instability. The two abstractions (fake backend vs monkeypatch real bindings) genuinely differ — they may not collapse into a single shape and forcing them would create the wrong base class.
|
||||
|
||||
## Phase Summary
|
||||
|
||||
### Phase 1 — Context Loading
|
||||
|
||||
- 3 task specs reviewed (AZ-527, AZ-333, AZ-334) from `_docs/02_tasks/done/`.
|
||||
- 5 doc inputs: `_docs/02_document/architecture.md`, `_docs/02_document/module-layout.md`, the 3 per-batch reviews (`batch_52_review.md` / `batch_53_review.md` / `batch_54_review.md`), and `_docs/02_document/components/01_c1_vio/description.md` + `02_c2_vpr/description.md`.
|
||||
- No `_docs/02_document/architecture_compliance_baseline.md` exists yet; the **Baseline Delta** section is omitted from this report (consistent with prior cumulative reviews).
|
||||
|
||||
### Phase 2 — Spec Compliance
|
||||
|
||||
Per-batch reviews covered AC-by-AC compliance for each task (B52: 6/6 + 1 NFR; B53: 10/10 + 1 NFR; B54: 11/11 + 1 NFR). Cross-batch checks:
|
||||
|
||||
- **AZ-333 + AZ-334 strategy-facade parity vs AZ-332 (OKVIS2)**: the two new strategies line up with OKVIS2's structural skeleton — module-level helpers (`_now_iso`, `_bias_norm`, `_se3_from_4x4`) + instance-method spine (`_classify_state`, `_tick_lost`, `_emit_transition`) + per-strategy geometry pipeline. The byte-equivalence is exactly what makes the AZ-331 factory + IT-12 comparative harness work — F1 captures it as the consolidation target, NOT as a drift defect.
|
||||
- **`_STRATEGY_TO_BUILD_FLAG` / `_STRATEGY_TO_MODULE` pre-wiring** in `runtime_root/vio_factory.py`: the 3 rows for `okvis2`, `vins_mono`, `klt_ransac` were added at AZ-331 land time and remain untouched by AZ-333 / AZ-334 — verified by reading the factory module (unchanged in this window). Strategy modules now actually exist for all 3 entries — no more silent ImportError surprises if a downstream test toggles a build flag ON.
|
||||
- **`vio.health` FDR record schema parity**: all three strategies emit FDR records with identical payload shape (`{state, consecutive_lost, bias_norm, strategy_label, frame_id}`) — verified by reading the `_emit_transition` bodies. The only difference is the `strategy_label` value; that field exists precisely to disambiguate at consumer-side.
|
||||
- **AZ-527 closure** of cumulative-49-51 F1: verified — `_assert_engine_output_dim` definitions exist in exactly 1 place (`c2_vpr/_engine_dim_assertion.py`); the 7 strategy modules import the helper.
|
||||
|
||||
No Spec-Gap findings.
|
||||
|
||||
### Phase 3 — Code Quality
|
||||
|
||||
- **SRP**: each new strategy and the new c2_vpr helper has a single clear concern. The c1_vio strategies' SRP boundary is at the geometry-pipeline level; the facade-spine duplication is a DRY concern (F1), not an SRP one.
|
||||
- **Error handling**: typed exception envelopes (`VioFatalError`, `VioInitializingError`, `RansacFilterError`, `ImuPreintegrationError`, `ConfigError`, `StrategyNotAvailableError`) used consistently across all three c1_vio strategies + the c2_vpr helper; no bare `except`; `__cause__` chaining preserved at every rewrap point.
|
||||
- **Naming**: aligned across the three c1_vio strategies — method shapes mirror line-for-line (which is the F1 target).
|
||||
- **Test quality**: B52, B53, B54 each follow the AAA pattern; behaviourally-meaningful assertions throughout; no "did not throw" placeholder tests. KLT/RANSAC's monkeypatch-based deterministic testing (F2 location) is the most novel pattern in the window — it correctly tests the facade's state machine without depending on real OpenCV geometry on synthetic correspondences; real-geometry validation is correctly deferred to C1-IT-12 (Jetson Tier-2 fixture).
|
||||
- **Test-environment-mirrors-prod gate**: still satisfied — fakes mirror the production binding interfaces (`Okvis2Backend`, `VinsMonoBackend` Protocol-shaped fakes; KLT/RANSAC monkey-patches the real `cv2` symbols).
|
||||
- **Annotation form**: `from __future__ import annotations` + bare type names — Ruff `UP037` clean across all 16 src files in the window.
|
||||
|
||||
### Phase 4 — Security Quick-Scan
|
||||
|
||||
No SQL, no `subprocess`, no `eval` / `exec`, no secrets. New code is pure-Python numerical wiring + FDR record emission + (for VINS-Mono) a pybind11 binding skeleton that is gated OFF by default (`BUILD_VINS_MONO=OFF`). Input validation present at the bindings boundary (numpy shape/dtype checks for 3×3 K, 4×4 pose, length-3 IMU vectors). n/a.
|
||||
|
||||
### Phase 5 — Performance Scan
|
||||
|
||||
- All three c1_vio strategies budget at p95 ≤ 80 ms on Tier-2 per ADR-002 (OKVIS2 + KLT/RANSAC bound by C1-PT-01; VINS-Mono exempt per task spec). Honest-covariance estimators avoid client-side floors (verified by AC-9 tier-2 tests in B53 + B54).
|
||||
- AZ-527's helper consolidation has zero hot-path impact — assertion runs at engine `create()` time (startup), not per frame.
|
||||
- No N+1, no unbounded buffers. FDR client capacity bounded by AZ-273 ringbuf at the producer side; tests cap explicitly at 256.
|
||||
|
||||
### Phase 6 — Cross-Task Consistency
|
||||
|
||||
- **Strategy-facade pattern parity across the c1_vio triplet**: confirmed — same spine, same FDR record kind (`vio.health`), same state enum (`VioState{INIT,TRACKING,DEGRADED,LOST}`), same error envelope. F1 IS the cost of this parity until AZ-528 lands.
|
||||
- **`assert_engine_output_dim` import consistency across c2_vpr**: all 7 c2_vpr strategy modules now import the helper — verified by AZ-527's import-grep regression guard (`test_ac4_seven_strategy_modules_import_the_helper`).
|
||||
- **No cross-component leakage**: c1_vio facade-spine duplication is intra-component (3 c1_vio modules); not duplicated into c2_vpr or any other component. c2_vpr `_assert_engine_output_dim` consolidation kept the helper c2-internal (underscore-prefixed module, not exported). Component boundaries preserved.
|
||||
- **FDR record schema**: no schema-level additions in this window. The 3 c1_vio strategies emit the same `vio.health` record kind; the 5 c2_vpr secondary backbones (from B50-51) continue to emit `vpr.embedding_complete` / `vpr.retrieval_complete` / `vpr.query_failed`. Verified by `tests/unit/test_az272_fdr_record_schema.py` (passes unmodified).
|
||||
- **`KltRansacConfig` schema extension**: added to `c1_vio/config.py` + exported via `c1_vio/__init__.py`; `KNOWN_STRATEGIES` already had `klt_ransac` from AZ-331. Wiring is symmetric with `Okvis2Config` + `VinsMonoConfig` — verified by `test_az269_config_loader.py` (passes).
|
||||
- **AZ-527 closure** of cumulative-49-51 F1: verified — zero `_assert_engine_output_dim` definitions remain anywhere under `src/gps_denied_onboard/components/c2_vpr/` except in `_engine_dim_assertion.py`.
|
||||
|
||||
### Phase 7 — Architecture Compliance
|
||||
|
||||
1. **Layer direction**: all changed files in this window import only from L1 substrate (`_types`, `helpers/*`, `config`, `logging`, `fdr_client`, `clock`, `errors`) or intra-component siblings. Spot-checked all 3 c1_vio strategies + the new c2_vpr helper + the 7 modified c2_vpr strategies. No upward imports detected.
|
||||
2. **Public API respect**: AZ-527's helper is underscore-prefixed (`_engine_dim_assertion.py`) and NOT exported from `c2_vpr/__init__.py` — confirmed. `KltRansacStrategy` and `VinsMonoStrategy` are deliberately NOT exported from `c1_vio/__init__.py` (lazy import only via `runtime_root.vio_factory`) — matches the OKVIS2 precedent and the Risk-2/Risk-3 mitigation pattern. `KltRansacConfig` + `VinsMonoConfig` ARE exported (correct — they participate in the AZ-269 config-loader's structural typing). Verified by `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies` (passes).
|
||||
3. **No new cyclic deps**: built-import graph of changed files + direct dependencies; no cycles introduced. All three c1_vio strategies + the new c2_vpr helper are leaves in the import graph.
|
||||
4. **Duplicate symbols across components**: F1 above is **intra-component** (scoped to `c1_vio/*.py`). No cross-component duplication detected. The c2_vpr helper consolidation explicitly stayed c2-internal — no new cross-component shared symbol introduced.
|
||||
5. **Cross-cutting concerns not locally re-implemented**: AZ-526 + AZ-527 + AZ-508 have moved the genuinely cross-cutting helpers (`iso_ts_now`, `iso_ts_from_clock`) into `helpers/iso_timestamps`. The remaining open intra-component pattern (`c1_vio` orchestration spine, `c2_vpr` engine-dim assertion) is consciously kept component-internal — engine output-shape contracts and strategy state machines are correctly component-scoped concerns, not shared/* concerns. F1's AZ-528 keeps the spine inside `c1_vio/`, not in `shared/helpers`.
|
||||
6. **Native binding location**: VINS-Mono added `src/gps_denied_onboard/components/c1_vio/_native/vins_mono_binding.cpp` next to the facade and `cpp/vins_mono/CMakeLists.txt` for the source build — matches `module-layout.md` rule #4. KLT/RANSAC is pure Python (no `_native/` directory by design) — matches AZ-334's architectural decision. Both gate paths (`BUILD_VINS_MONO`, `BUILD_KLT_RANSAC`) wired correctly at the AZ-331 factory.
|
||||
|
||||
**No Critical, no High Architecture findings in this window.**
|
||||
|
||||
## Verdict Justification
|
||||
|
||||
- Critical findings: 0
|
||||
- High findings: 0
|
||||
- Medium findings: 1 (F1 — already addressed via AZ-528 ticket creation)
|
||||
- Low findings: 1 (F2 — deferred to later hygiene pass; informational carry-forward)
|
||||
|
||||
**PASS_WITH_WARNINGS** — auto-chain to next batch is allowed per implement skill Step 14.5. The F1 finding has an open Jira ticket (AZ-528) ready to be sequenced into a future batch; F2 is informational and does not block.
|
||||
|
||||
## Recommended Follow-up
|
||||
|
||||
1. **Sequence AZ-528 before any future c1_vio strategy or VIO-facing change** (none planned in current scope, but defensively before any AZ-358 / AZ-335 / hypothetical secondary VIO). 3 points, 1 new module + 3 small edits + 1 test.
|
||||
2. **F2 carry-forward**: revisit when the next c1_vio touch lands (e.g. AZ-335 warm-start hint persistence). Sized at 1-2 points. Do NOT bundle into AZ-528 — different abstraction layer.
|
||||
3. **Cumulative-49-51 F2 carry-forward** (AC-10 spec wording drift across AZ-337/338/339/340 specs): still open; documentation-only; fold into any future c2_vpr spec touch.
|
||||
4. **No other carry-over.** All previous cumulative findings are now either CLOSED (B49-51 F1 / F3 closed by AZ-526; B49-51 F1 closed by AZ-527 / B52), tracked (AZ-528 for B52-54 F1), or informational (B49-51 F2 + B52-54 F2).
|
||||
|
||||
## Next Cumulative Review
|
||||
|
||||
- **Trigger**: after Batch 57 (every K=3).
|
||||
- **Range**: batches 55-57.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 53 (AZ-333 — C1 VINS-Mono Strategy)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Scope
|
||||
|
||||
Single-task batch implementing `VinsMonoStrategy`, the research-only
|
||||
loosely-coupled comparative VIO that participates in the IT-12
|
||||
comparative-study research binary only.
|
||||
|
||||
### Changed files
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
|
||||
`VinsMonoConfig`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
|
||||
`VinsMonoConfig` dataclass + `vins_mono` field on `C1VioConfig`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/vins_mono.py` — new Python
|
||||
facade (533 lines).
|
||||
- `src/gps_denied_onboard/components/c1_vio/_native/vins_mono_binding.cpp`
|
||||
— new pybind11 binding skeleton (≈275 lines).
|
||||
- `cpp/vins_mono/CMakeLists.txt` — replace placeholder with full
|
||||
pybind11 + linker wiring (≈70 lines).
|
||||
- `tests/unit/c1_vio/conftest.py` — extend with `FakeVinsMonoBackend`
|
||||
+ 3 fake exception types + `fake_vins_mono_binding` fixture.
|
||||
- `tests/unit/c1_vio/test_vins_mono_strategy.py` — new (518 lines)
|
||||
covering AC-1..AC-10 + 2 tier2 perf/honesty tests.
|
||||
- `tests/unit/c1_vio/test_protocol_conformance.py` — drop `vins_mono`
|
||||
from `_STRATEGIES_WITHOUT_PY_MODULE` so the existing parametrised
|
||||
factory test routes through the new strategy correctly.
|
||||
|
||||
## Phase 2 — Spec Compliance
|
||||
|
||||
| AC | Test | Verified |
|
||||
|-------|-------------------------------------------------------------------|----------|
|
||||
| AC-1 | `test_ac1_current_strategy_label_returns_vins_mono` | ✓ |
|
||||
| AC-2 | `test_ac2_process_frame_returns_vio_output_with_frame_id` | ✓ |
|
||||
| AC-3 | `test_ac3_backend_exceptions_rewrap_to_vio_error_family` ×2 + | ✓ |
|
||||
| | `_optimization_exception_during_init_rewraps_to_initializing` + | |
|
||||
| | `_unmapped_runtime_error_rewraps_to_vio_fatal` | |
|
||||
| AC-4 | `test_ac4_reset_to_warm_start_clears_and_seeds` + | ✓ |
|
||||
| | `_is_idempotent` | |
|
||||
| AC-5 | `test_ac5_health_snapshot_init_then_tracking` | ✓ |
|
||||
| AC-6 | `test_ac6_degraded_on_feature_loss_emits_vio_output` | ✓ |
|
||||
| AC-7 | `test_ac7_sustained_loss_raises_vio_fatal_error` | ✓ |
|
||||
| AC-8 | `test_ac8_strategy_module_not_imported_at_package_load` + | ✓ |
|
||||
| | `test_protocol_conformance.py::test_ac5_build_vio_strategy_*` | |
|
||||
| AC-9 | `test_ac9_honest_covariance_monotonic_during_degraded` (tier2) | ✓ |
|
||||
| AC-10 | `test_ac10_fdr_vio_health_emitted_per_transition` | ✓ |
|
||||
| NFR-perf-document | `test_nfr_perf_process_frame_records_p95` (tier2) | ✓ |
|
||||
|
||||
All 10 ACs mapped to tests; test suite reports 17/17 passing for the
|
||||
new `test_vins_mono_strategy.py` (with the 2 tier2 tests skipped on
|
||||
macOS/Linux dev runners as documented in the spec).
|
||||
|
||||
## Phase 3 — Code Quality
|
||||
|
||||
- **SOLID**: `VinsMonoStrategy` has a single responsibility (Python
|
||||
facade for VINS-Mono). Constructor injection per ADR-009. Closed for
|
||||
modification through the AZ-331 Protocol.
|
||||
- **Error handling**: error envelope closed at `VioError` family; no
|
||||
raw backend exception leaks. RuntimeError catch-all for unmapped
|
||||
cases. PASS.
|
||||
- **Naming**: matches the OKVIS2 facade naming exactly (intentional —
|
||||
IT-12 harness substitutability).
|
||||
- **Complexity**: `process_frame` is ~70 lines — same shape as
|
||||
`okvis2.py::process_frame`; not split further because the linear
|
||||
except-ladder is the clearest expression of the rewrap contract.
|
||||
- **DRY**: see F1 below.
|
||||
- **Test quality**: each AC has a behaviourally-meaningful assertion
|
||||
(covariance SPD, frame_id echoed, transition states ordered, etc.).
|
||||
No "did not throw" placeholder tests.
|
||||
- **Dead code**: none.
|
||||
|
||||
## Phase 4 — Security Quick-Scan
|
||||
|
||||
- No SQL / command injection paths. No `subprocess(shell=True)`,
|
||||
`eval`, `exec`.
|
||||
- No hardcoded secrets, API keys, or credentials.
|
||||
- Input validation: numpy array shapes / dtypes validated at the
|
||||
pybind11 boundary (3×3 K, 4×4 pose, length-3 IMU vectors).
|
||||
`VinsMonoConfig.__post_init__` validates all knob ranges.
|
||||
- Sensitive data: per-frame DEBUG log defaults OFF (matches
|
||||
`description.md` § 9 logging hygiene).
|
||||
|
||||
PASS.
|
||||
|
||||
## Phase 5 — Performance Scan
|
||||
|
||||
- Hot path: `process_frame`. IMU push loop is `O(samples_per_window)`
|
||||
— unavoidable.
|
||||
- `get_latest_output` is a single dict copy under a mutex on the C++
|
||||
side; cost is dominated by the numpy view construction (zero-copy).
|
||||
- No N+1, no unbounded buffers (`fdr_client` capacity bounded at 256
|
||||
in tests, real client uses ringbuf from AZ-273).
|
||||
|
||||
PASS.
|
||||
|
||||
## Phase 6 — Cross-Task Consistency
|
||||
|
||||
N/A — single-task batch.
|
||||
|
||||
## Phase 7 — Architecture Compliance
|
||||
|
||||
- **Layer direction**: `vins_mono.py` imports from `_types.nav`,
|
||||
`clock`, `components.c1_vio.errors`, `fdr_client`, `logging` — all
|
||||
L1/L2 substrate per the c1 layering. PASS.
|
||||
- **Public API respect**: `VinsMonoConfig` exported through
|
||||
`c1_vio/__init__.py`; `VinsMonoStrategy` deliberately NOT exported
|
||||
(lazy import only via `runtime_root.vio_factory`) — matches
|
||||
Risk-2/Risk-3 pattern from OKVIS2. PASS.
|
||||
- **No new cyclic dependencies**: introduced module is a leaf — no
|
||||
back-edges to its own importers.
|
||||
- **Native binding location**: `_native/vins_mono_binding.cpp` matches
|
||||
`module-layout.md` rule #4 (binding lives next to facade, native
|
||||
source under `cpp/vins_mono/`).
|
||||
- **Build flag respect**: `BUILD_VINS_MONO=OFF` keeps the binding `.so`
|
||||
out of the build graph and the AZ-331 factory raises
|
||||
`StrategyNotAvailableError` before any import — Risk-3 mitigation
|
||||
intact for airborne / operator-tooling / replay-cli binaries.
|
||||
|
||||
PASS.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability | `src/gps_denied_onboard/components/c1_vio/vins_mono.py` | Structural duplication with `okvis2.py` (~80% mirrored) — tracked for future hygiene PBI after AZ-334 lands |
|
||||
| 2 | Low | Maintainability | `tests/unit/c1_vio/conftest.py` | `FakeVinsMonoBackend` mirrors `FakeOkvis2Backend` ~1:1 — same deferred-consolidation note |
|
||||
|
||||
### Finding details
|
||||
|
||||
**F1: Structural duplication of strategy facade** (Low / Maintainability)
|
||||
|
||||
- Location: `src/gps_denied_onboard/components/c1_vio/vins_mono.py` vs
|
||||
`src/gps_denied_onboard/components/c1_vio/okvis2.py`
|
||||
- Description: The new `VinsMonoStrategy` mirrors `Okvis2Strategy`
|
||||
~80% verbatim — the constructor wiring, `_classify_state`,
|
||||
`_tick_lost`, `_emit_transition`, `_build_vio_output`, `_bias_norm`,
|
||||
`_now_iso`, `_se3_from_4x4`, `_frame_ts_ns`, `_frame_image`, and the
|
||||
full `process_frame` except-ladder are byte-equivalent modulo the
|
||||
exception class names and producer ID. This is intentional for now
|
||||
because (a) the AZ-331 factory + IT-12 comparative harness require
|
||||
the two to be drop-in substitutable, (b) the consolidation target
|
||||
is ill-defined until KltRansacStrategy lands (AZ-334 — fundamentally
|
||||
different shape: pure-Python, no native binding), and (c) extracting
|
||||
a base class now would force premature coupling between the
|
||||
research-only and production-default strategies.
|
||||
- Suggestion: defer consolidation to a hygiene PBI scheduled AFTER
|
||||
AZ-334 lands. At that point all three strategy shapes are visible
|
||||
and the right factoring (template method? composition over a shared
|
||||
state-machine helper?) will be obvious. Mirrors the AZ-340 → AZ-527
|
||||
precedent for c2_vpr secondary strategies. Track informally; do
|
||||
NOT create the PBI yet — the next cumulative review (batches 52-54)
|
||||
will surface this naturally.
|
||||
- Task: AZ-333
|
||||
|
||||
**F2: Test fake duplication** (Low / Maintainability)
|
||||
|
||||
- Location: `tests/unit/c1_vio/conftest.py` (`FakeVinsMonoBackend`
|
||||
vs `FakeOkvis2Backend`)
|
||||
- Description: `FakeVinsMonoBackend` is a near-copy of
|
||||
`FakeOkvis2Backend` with renamed exceptions. The shared
|
||||
`ScriptedOutput` dataclass + `_make_default_payload` helper IS
|
||||
reused (good — the productive cut), but the backend class itself
|
||||
duplicates the queue-driven scripting pattern. Same deferred
|
||||
consolidation note as F1; both should be addressed together so the
|
||||
facade-side base class and the test-side base fake align.
|
||||
- Suggestion: same as F1 — defer to the post-AZ-334 hygiene PBI.
|
||||
- Task: AZ-333
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS_WITH_WARNINGS** — two Low-severity duplication findings, both
|
||||
intentionally-deferred for the post-AZ-334 hygiene PBI. No Critical,
|
||||
High, or Medium findings. All 10 ACs covered with passing tests. The
|
||||
unrelated `c12_operator_orchestrator/test_cli_console_script.py::test_cold_start_under_500ms_p99`
|
||||
failure observed in the full-suite run is a pre-existing
|
||||
environment-dependent perf flake (worst-after-trim 997 ms vs 500 ms
|
||||
threshold on macOS dev runner) with no touchpoint to c1_vio; reported
|
||||
to the user, not blocking this batch.
|
||||
@@ -0,0 +1,242 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 54 (AZ-334 — C1 KLT/RANSAC Strategy)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
|
||||
## Scope
|
||||
|
||||
Single-task batch implementing `KltRansacStrategy`, the mandatory
|
||||
simple-baseline `VioStrategy` that satisfies the ADR-002 engine rule
|
||||
(every component MUST ship a simple-baseline strategy alongside its
|
||||
production-default). Pure-Python over OpenCV's `cv2.goodFeaturesToTrack`
|
||||
/ `cv2.calcOpticalFlowPyrLK` / `cv2.findEssentialMat` / `cv2.recoverPose`
|
||||
path; no C++/pybind11 native binding by design — Tier-0 workstation can
|
||||
run the strategy with `pip install opencv-python` only.
|
||||
|
||||
### Changed files
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` — new
|
||||
Python facade (~770 lines, including module docstring + AC mapping
|
||||
+ risk-mitigation notes).
|
||||
- `src/gps_denied_onboard/components/c1_vio/config.py` — add
|
||||
`KltRansacConfig` dataclass + `klt_ransac` field on `C1VioConfig`;
|
||||
`__all__` updated; `KNOWN_STRATEGIES` already had `klt_ransac`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/__init__.py` — export
|
||||
`KltRansacConfig`.
|
||||
- `cpp/klt_ransac/CMakeLists.txt` — replace placeholder message
|
||||
with a deliberate "pure-Python; no native target" explanation so
|
||||
the build graph stays symmetric with `cpp/okvis2/` and
|
||||
`cpp/vins_mono/` while the `BUILD_KLT_RANSAC=ON` flag only gates
|
||||
the Python module import at the AZ-331 composition-root factory.
|
||||
- `tests/unit/c1_vio/test_klt_ransac_strategy.py` — new test module
|
||||
covering AC-1..AC-11 + NFR-perf (~990 lines, 25 tests; 23 pass + 2
|
||||
tier-2 skipped on dev/CI runners).
|
||||
- `tests/unit/c1_vio/test_protocol_conformance.py` — introduce the
|
||||
`_STRATEGIES_WITHOUT_NATIVE_BINDING` category and route
|
||||
`klt_ransac` through it inside `test_ac5_build_vio_strategy_flag_on_but_module_missing`
|
||||
so the parametrised factory test correctly handles the pure-Python
|
||||
shape (no native binding to fail on).
|
||||
|
||||
## Phase 2 — Spec Compliance
|
||||
|
||||
| AC | Test | Verified |
|
||||
|-------|----------------------------------------------------------------------------|----------|
|
||||
| AC-1 | `test_ac1_current_strategy_label_returns_klt_ransac` + | ✓ |
|
||||
| | `test_ac1_constructor_rejects_mismatched_strategy_label` | |
|
||||
| AC-2 | `test_ac2_first_frame_emits_init_state_with_identity_pose` | ✓ |
|
||||
| AC-3 | `test_ac3_steady_state_frame_emits_pose_and_spd_covariance` | ✓ |
|
||||
| AC-4 | `test_ac4_cv2_error_in_find_essential_mat_rewrapped_to_vio_fatal_error` + | ✓ |
|
||||
| | `test_ac4_cv2_error_in_recover_pose_rewrapped_to_vio_fatal_error` | |
|
||||
| AC-5 | `test_ac5_reset_to_warm_start_clears_feature_buffer_and_seeds_bias` + | ✓ |
|
||||
| | `test_ac5_reset_to_warm_start_idempotent_across_consecutive_calls` + | |
|
||||
| | `test_ac5_reset_to_warm_start_rejects_non_pose3_hint` | |
|
||||
| AC-6 | `test_ac6_low_inlier_count_emits_degraded_with_monotonic_covariance` | ✓ |
|
||||
| AC-7 | `test_ac7_sustained_pose_recovery_failure_raises_vio_fatal_error` | ✓ |
|
||||
| AC-8 | `test_ac8_strategy_module_not_imported_at_package_load` + | ✓ |
|
||||
| | `test_protocol_conformance.py::test_ac5_build_vio_strategy_flag_*` | |
|
||||
| AC-9 | `test_ac9_honest_covariance_monotonic_during_degraded` (tier2) | ✓ |
|
||||
| AC-10 | `test_ac10_fdr_vio_health_emitted_per_transition` | ✓ |
|
||||
| AC-11 | `test_ac11_source_has_no_camera_id_literals` + | ✓ |
|
||||
| | `test_ac11_strategy_handles_two_distinct_calibrations` | |
|
||||
| NFR-perf | `test_nfr_perf_process_frame_records_p95` (tier2) | ✓ |
|
||||
|
||||
All 11 ACs + NFR-perf mapped to passing tests. Test suite reports
|
||||
25 tests, 23 pass + 2 tier2 skipped on the standard dev/CI runner;
|
||||
all 25 pass under `GPS_DENIED_TIER=2`. Adjacent regression:
|
||||
`tests/unit/c1_vio/` reports 95 passed + 6 skipped (6 = the 2
|
||||
KLT-tier2 + 2 OKVIS2-tier2 + 2 VINS-Mono-tier2 tests); config-loader
|
||||
and compose-root suites green (17 passed).
|
||||
|
||||
## Phase 3 — Code Quality
|
||||
|
||||
- **SOLID**: `KltRansacStrategy` has a single responsibility (Python
|
||||
facade over OpenCV's KLT/RANSAC path). Constructor injection per
|
||||
ADR-009 — `Config` + `FdrClient` enter explicitly; `Clock` is
|
||||
optional with a `WallClock` default; the AZ-276 `ImuPreintegrator`
|
||||
is constructed lazily on the first `process_frame` call (it
|
||||
requires the per-call `CameraCalibration` which is not available
|
||||
at constructor time — matches the existing factory pattern across
|
||||
OKVIS2 / VINS-Mono).
|
||||
- **Error handling**: error envelope closed at the `VioError` family;
|
||||
every OpenCV `cv2.error` is caught at each call site and rewrapped
|
||||
into `VioFatalError` with `__cause__` chaining (AC-4). Pose-recovery
|
||||
failures route through `_pose_recovery_failed` which raises
|
||||
`VioInitializingError` until `lost_frame_threshold` is exhausted,
|
||||
then escalates to `VioFatalError` (AC-7). RansacFilterError +
|
||||
ImuPreintegrationError are also caught and rewrapped.
|
||||
- **Naming**: matches the OKVIS2 / VINS-Mono facade naming exactly
|
||||
(intentional — IT-12 harness substitutability).
|
||||
- **Complexity**: `process_frame` is ~120 lines; the dominant cost is
|
||||
the explicit step-numbered ladder (IMU push → grayscale → first-frame
|
||||
branch → KLT track → RANSAC filter → essential-matrix → pose recover
|
||||
→ covariance → VioOutput build → state classify → re-seed features).
|
||||
Splitting further would obscure the linear flow that maps 1:1 onto
|
||||
the task spec's "Outcome" numbered list.
|
||||
- **DRY**: structural duplication with OKVIS2 / VINS-Mono facades —
|
||||
see F1 below; deliberately deferred to the post-batch-54 hygiene
|
||||
PBI (now scheduled by the next cumulative review, batches 52-54).
|
||||
- **Test quality**: each AC has a behaviourally-meaningful assertion
|
||||
(covariance SPD, frame_id echoed, transition states ordered,
|
||||
monotonic covariance growth during DEGRADED, etc.). No "did not
|
||||
throw" placeholder tests. AC-3 / AC-6 / AC-9 / AC-10 / AC-11 /
|
||||
NFR-perf monkeypatch `cv2.findEssentialMat` + `cv2.recoverPose`
|
||||
+ `RansacFilter.filter_correspondences` to deterministic values so
|
||||
the unit suite exercises the FACADE's state machine without
|
||||
depending on real OpenCV geometry on synthetic correspondences;
|
||||
real-geometry validation lives in C1-IT-12 (Jetson Tier-2 fixture).
|
||||
- **Dead code**: none. `_drain_into_list` removed from the test
|
||||
helper after the simpler `_drain` was introduced.
|
||||
|
||||
## Phase 4 — Security Quick-Scan
|
||||
|
||||
- No SQL / command injection paths. No `subprocess(shell=True)`,
|
||||
`eval`, `exec`.
|
||||
- No hardcoded secrets, API keys, or credentials.
|
||||
- Input validation: `_intrinsics_3x3` rejects non-3x3 K with
|
||||
`VioFatalError`; `_grayscale` rejects unsupported image shapes;
|
||||
`KltRansacConfig.__post_init__` validates every knob range
|
||||
(max_corners ≥ 4, klt_window_size_px odd ≥ 3, klt_pyramid_levels
|
||||
≥ 1, min_features_for_pose ≥ 5, ransac_inlier_ratio in (0, 1],
|
||||
essential_matrix_ransac_threshold_px > 0).
|
||||
- Sensitive data: per-frame DEBUG log defaults OFF
|
||||
(`KltRansacConfig.per_frame_debug_log = False`) — matches
|
||||
`description.md` § 9 logging hygiene.
|
||||
|
||||
PASS.
|
||||
|
||||
## Phase 5 — Performance Scan
|
||||
|
||||
- Hot path: `process_frame`. IMU push loop is
|
||||
`O(samples_per_window)` — unavoidable. KLT track + RANSAC are
|
||||
multi-threaded internally by OpenCV; bound at 30 % of one core
|
||||
per ADR-002 budget partition.
|
||||
- No N+1, no unbounded buffers (FdrClient capacity bounded by
|
||||
AZ-273 ringbuf; the strategy keeps a single
|
||||
`_prev_features` numpy array sized at `max_corners`).
|
||||
- Covariance estimator: `_estimate_covariance` does one
|
||||
`np.eye(6) * scalar` — O(1).
|
||||
- AC-9 honest-covariance: the residual_var / DOF formula has no
|
||||
client-side floor; cov Frobenius grows monotonically as
|
||||
inlier_count drops (verified in tier-2 test).
|
||||
|
||||
PASS.
|
||||
|
||||
## Phase 6 — Cross-Task Consistency
|
||||
|
||||
N/A — single-task batch. Cross-task consistency with AZ-332 +
|
||||
AZ-333 is tracked in the next cumulative review (batches 52-54),
|
||||
which will see all three strategy facades and the duplication
|
||||
finding (F1) at the same time.
|
||||
|
||||
## Phase 7 — Architecture Compliance
|
||||
|
||||
- **Layer direction**: `klt_ransac.py` imports from `_types.nav`,
|
||||
`_types.calibration` (TYPE_CHECKING only), `clock.wall_clock`,
|
||||
`components.c1_vio.config` (TYPE_CHECKING only),
|
||||
`components.c1_vio.errors`, `fdr_client`, `helpers.imu_preintegrator`,
|
||||
`helpers.ransac_filter`, `logging` — all L1/L2 substrate per the
|
||||
c1 layering. PASS.
|
||||
- **Public API respect**: `KltRansacConfig` exported through
|
||||
`c1_vio/__init__.py`; `KltRansacStrategy` deliberately NOT exported
|
||||
(lazy import only via `runtime_root.vio_factory`) — matches the
|
||||
Risk-2/Risk-3 pattern from OKVIS2 and VINS-Mono. PASS.
|
||||
- **No new cyclic dependencies**: introduced module is a leaf — no
|
||||
back-edges to its own importers.
|
||||
- **Native binding location**: NONE by design. `cpp/klt_ransac/`
|
||||
carries a CMakeLists that returns a STATUS message documenting
|
||||
the absence of native target; the directory is preserved for
|
||||
build-graph symmetry with `cpp/okvis2/` and `cpp/vins_mono/`.
|
||||
- **Build flag respect**: `BUILD_KLT_RANSAC=OFF` keeps the
|
||||
composition-root factory from importing the strategy module; the
|
||||
AZ-331 factory raises `StrategyNotAvailableError` before any
|
||||
import — Risk-3 mitigation intact for operator-tooling binaries
|
||||
(which do not need any VIO at all).
|
||||
- **AC-11 camera agnostic**: source CI-grep gate verifies no
|
||||
`adti20` / `adti26` literals in executable code (docstrings are
|
||||
excluded via AST-aware stripping in the test). PASS.
|
||||
|
||||
PASS.
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|----------|-----------|-------|
|
||||
| 1 | Low | Maintainability | `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` | Structural duplication with `okvis2.py` / `vins_mono.py` — now scheduled for post-batch-54 hygiene PBI via cumulative review |
|
||||
| 2 | Low | Maintainability | `tests/unit/c1_vio/test_klt_ransac_strategy.py` | `_patch_pose_recovery` helper is bespoke per-strategy; the same patching pattern could plausibly be shared with OKVIS2 / VINS-Mono fake-binding fixtures |
|
||||
|
||||
### Finding details
|
||||
|
||||
**F1: Structural duplication of strategy facade** (Low / Maintainability)
|
||||
|
||||
- Location: `src/gps_denied_onboard/components/c1_vio/klt_ransac.py`
|
||||
vs `src/gps_denied_onboard/components/c1_vio/okvis2.py` /
|
||||
`vins_mono.py`
|
||||
- Description: The new `KltRansacStrategy` mirrors `Okvis2Strategy`
|
||||
+ `VinsMonoStrategy` ~70 % verbatim on the orchestration spine —
|
||||
`_classify_state`, `_tick_lost`, `_emit_transition`,
|
||||
`_bias_norm`, `_now_iso`, `_se3_from_4x4`, the constructor strategy-
|
||||
label guard, and the FDR record-emit shape are byte-equivalent
|
||||
modulo strategy-label constants. The geometry-specific pipeline
|
||||
(KLT seed/track, RANSAC filter, findEssentialMat, recoverPose,
|
||||
residual-scatter covariance) IS unique to this strategy and lives
|
||||
in its own module — that boundary is correct. The shared
|
||||
orchestration spine is the consolidation target tracked by the
|
||||
hygiene PBI deferred since batch 53; the cumulative review
|
||||
scheduled for batches 52-54 (next trigger) will formally raise the
|
||||
PBI now that all three strategy shapes are visible.
|
||||
- Suggestion: defer (one more time) to the cumulative review for
|
||||
batches 52-54 — the right factoring is now visible (template-
|
||||
method base class for the orchestration spine + per-strategy
|
||||
geometry hook). Do NOT create the PBI ad-hoc here; let the
|
||||
cumulative review own the cross-batch refactor scope.
|
||||
- Task: AZ-334
|
||||
|
||||
**F2: Test patching helper could be shared** (Low / Maintainability)
|
||||
|
||||
- Location: `tests/unit/c1_vio/test_klt_ransac_strategy.py`
|
||||
(`_patch_pose_recovery`) vs `tests/unit/c1_vio/conftest.py`
|
||||
(`FakeOkvis2Backend` / `FakeVinsMonoBackend`)
|
||||
- Description: KLT/RANSAC uses real OpenCV bindings (no fake-binding
|
||||
fixture) so the existing conftest fakes don't apply directly. The
|
||||
per-test helper `_patch_pose_recovery` does the equivalent job of
|
||||
forcing a deterministic-success path. This is a different
|
||||
abstraction shape from the conftest fakes but lives at the same
|
||||
layer; consolidating with the post-batch-54 hygiene PBI would let
|
||||
all three strategies share a single per-strategy "ScriptedSuccess"
|
||||
fixture surface.
|
||||
- Suggestion: same as F1 — let the cumulative review's hygiene PBI
|
||||
own the cross-cutting test-fixture refactor.
|
||||
- Task: AZ-334
|
||||
|
||||
## Verdict
|
||||
|
||||
**PASS_WITH_WARNINGS** — two Low-severity duplication findings, both
|
||||
intentionally deferred and now formally in scope for the next
|
||||
cumulative review (batches 52-54). No Critical, High, or Medium
|
||||
findings. All 11 ACs + NFR-perf covered with passing tests.
|
||||
|
||||
Pre-existing environment-dependent perf flake noted in batch 53
|
||||
(`tests/unit/c12_operator_orchestrator/test_cli_console_script.py::test_cold_start_under_500ms_p99`)
|
||||
is still environmental and untouched by this batch — reported, not
|
||||
blocking.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Code Review Report
|
||||
|
||||
**Batch**: 55 (AZ-528 — c1_vio strategy facade orchestration-spine consolidation)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: PASS
|
||||
|
||||
## Scope
|
||||
|
||||
Single-task hygiene batch closing Finding F1 from
|
||||
`_docs/03_implementation/cumulative_review_batches_52-54_cycle1_report.md`.
|
||||
Replaces the 3-way byte-equivalent orchestration-spine duplication
|
||||
across `okvis2.py`, `vins_mono.py`, `klt_ransac.py` with a single
|
||||
c1-internal helper module `_facade_spine.py`. No behaviour change;
|
||||
existing AZ-332 / AZ-333 / AZ-334 AC tests pass unmodified.
|
||||
|
||||
### Changed files
|
||||
|
||||
- `src/gps_denied_onboard/components/c1_vio/_facade_spine.py` — NEW
|
||||
c1-internal helper. Exports 5 stateless free functions (`now_iso`,
|
||||
`bias_norm`, `se3_from_4x4`, `frame_ts_ns`, `frame_image`) and one
|
||||
mixin class (`FacadeSpine`) providing `_classify_state`,
|
||||
`_tick_lost`, `_emit_transition`. 225 lines including docstring +
|
||||
attribute declarations.
|
||||
- `src/gps_denied_onboard/components/c1_vio/okvis2.py` — inherit from
|
||||
`FacadeSpine`; import the 5 free functions; delete the 5 local
|
||||
module-level definitions + 3 instance methods + unused
|
||||
`_BIAS_NORM_FLOOR` constant; set 3 new spine-required attributes in
|
||||
`__init__` (`_feature_threshold`, `_producer_id`, `_strategy_label`).
|
||||
- `src/gps_denied_onboard/components/c1_vio/vins_mono.py` — same
|
||||
pattern as `okvis2.py`.
|
||||
- `src/gps_denied_onboard/components/c1_vio/klt_ransac.py` — same
|
||||
pattern; reuses `_grayscale` (KLT/RANSAC-specific, not consolidated).
|
||||
Spine threshold attribute reads `_cfg.min_features_for_pose` (the
|
||||
KLT-side equivalent of OKVIS2's `degraded_feature_threshold`).
|
||||
- `tests/unit/c1_vio/test_az528_facade_spine.py` — NEW. 19 tests
|
||||
covering AC-1..AC-8 + a Risk-1 mitigation test that statically
|
||||
verifies every concrete strategy's `__init__` sets every
|
||||
spine-required attribute.
|
||||
|
||||
## Phase 2 — Spec Compliance
|
||||
|
||||
| AC | Test | Verified |
|
||||
|-------|-----------------------------------------------------------------------------------------------|----------|
|
||||
| AC-1 | `test_ac1_helper_module_exposes_documented_surface` | ✓ |
|
||||
| AC-2 | `test_ac2_now_iso_returns_aware_utc_with_plus_offset` | ✓ |
|
||||
| AC-3 | `test_ac3_bias_norm_matches_l2_formula` + `test_ac3_bias_norm_includes_gyro_component` | ✓ |
|
||||
| AC-4 | `test_ac4_se3_from_4x4_builds_identity_pose` | ✓ |
|
||||
| AC-5 | `test_ac5_classify_state_returns_init_during_warmup` + tracking + degraded | ✓ |
|
||||
| AC-6 | `test_ac6_tick_lost_demotes_tracking_to_degraded_first_call` + escalates-to-lost | ✓ |
|
||||
| AC-7 | `test_ac7_emit_transition_no_record_on_steady_state` + one-record-per-change + idempotent | ✓ |
|
||||
| AC-8 | `test_ac8_no_duplicated_free_functions_remain_in_strategy_module` (parametrised over 3 files) | ✓ |
|
||||
| AC-9 | Existing AZ-332 / AZ-333 / AZ-334 AC tests — all unmodified, all pass | ✓ |
|
||||
| AC-10 | `tests/unit/test_az270_compose_root.py::test_ac6_only_compose_root_imports_concrete_strategies` | ✓ |
|
||||
|
||||
Test counts:
|
||||
|
||||
- `tests/unit/c1_vio/test_az528_facade_spine.py` — 19 tests, all pass.
|
||||
- `tests/unit/c1_vio/` total — 120 collected, 114 pass + 6 tier-2
|
||||
skipped (unchanged from pre-AZ-528 state).
|
||||
- Adjacent regression suite (`test_az270_compose_root`, `test_az272_fdr_record_schema`,
|
||||
`test_az273_fdr_client_ringbuf`, `test_ac1_scaffold_layout`,
|
||||
`tests/unit/c1_vio/`) — 237 pass + 6 skipped.
|
||||
|
||||
Scope discipline: only the 5 files in the task spec's "Included"
|
||||
list were touched; no test files outside the new
|
||||
`test_az528_facade_spine.py` were modified (AC-9 hard requirement).
|
||||
|
||||
## Phase 3 — Code Quality
|
||||
|
||||
- **SRP**: `FacadeSpine` owns one responsibility — the c1_vio
|
||||
strategy state machine + FDR `vio.health` record emission. Free
|
||||
functions are stateless pure utilities. Each concrete strategy
|
||||
retains its geometry pipeline + native-binding driver. Clean split.
|
||||
- **Naming**: instance attributes the mixin reads are documented in
|
||||
the class docstring; type annotations on the class declare them for
|
||||
IDE / type-checker consumption (Risk 1 mitigation).
|
||||
- **Comments**: deferred-consolidation comments referencing this PBI
|
||||
(`# post-AZ-334 hygiene PBI ...`) were removed from
|
||||
`klt_ransac.py` as required by the task spec.
|
||||
- **Dead code**: removed `_BIAS_NORM_FLOOR` from `okvis2.py` (verified
|
||||
via grep; not used anywhere in the c1_vio component or
|
||||
cross-component).
|
||||
- **DRY**: the consolidation eliminates ~120 lines of duplicated code
|
||||
across the three strategy modules.
|
||||
- **Test quality**: every test follows Arrange / Act / Assert; AC
|
||||
tests use parametrisation where appropriate (AC-8 over the 3
|
||||
strategy modules).
|
||||
|
||||
## Phase 4 — Security Quick-Scan
|
||||
|
||||
N/A — pure refactor, no new inputs, no I/O, no string interpolation
|
||||
into queries, no subprocess invocation. The free functions take only
|
||||
typed DTOs (`ImuBias`, `NavCameraFrame`, `np.ndarray`); the mixin
|
||||
methods only mutate instance state.
|
||||
|
||||
## Phase 5 — Performance
|
||||
|
||||
No hot-path regression. The mixin call sites are byte-equivalent to
|
||||
the previous in-line definitions (same conditionals, same
|
||||
`FdrClient.enqueue` invocation, same string interpolation in
|
||||
`bias_norm`). The single new indirection is method dispatch through
|
||||
the mixin's MRO — Python class-method resolution is O(1) on a fixed
|
||||
MRO and is amortised inside the hot-path's existing call overhead.
|
||||
|
||||
## Phase 6 — Cross-Task Consistency
|
||||
|
||||
Single-task batch — N/A.
|
||||
|
||||
## Phase 7 — Architecture Compliance
|
||||
|
||||
- **Layer direction**: `_facade_spine.py` imports only from L1
|
||||
substrate (`_types.nav`, `components.c1_vio.errors`,
|
||||
`fdr_client.records`) + stdlib (`datetime`, `math`) + numpy. No
|
||||
imports from a higher layer; no imports from a sibling component.
|
||||
- **Public API respect**: `_facade_spine` is underscore-prefixed and
|
||||
NOT in `c1_vio/__init__.py`'s `__all__`. AZ-270's
|
||||
`test_ac6_only_compose_root_imports_concrete_strategies`
|
||||
regression-gate passes. Mirrors the AZ-527 precedent for
|
||||
`_assert_engine_output_dim` in `c2_vpr`.
|
||||
- **Cycles**: no new cyclic dependencies. `_facade_spine` is a leaf
|
||||
inside `c1_vio`; the 3 strategy modules already imported from
|
||||
`c1_vio.errors`, so the new sibling import slots in cleanly.
|
||||
- **Duplicate symbols**: AC-8 AST regression guard asserts the 5
|
||||
consolidated free-function names appear only in `_facade_spine.py`.
|
||||
- **Cross-cutting concerns**: the spine is a c1-internal concern by
|
||||
task-spec decision (component state machines + per-strategy FDR
|
||||
record producers do not generalise across c2..c5; AZ-507 carved
|
||||
that boundary intentionally). NOT hoisted to `shared/helpers/`.
|
||||
|
||||
## Findings
|
||||
|
||||
None.
|
||||
|
||||
## Conclusion
|
||||
|
||||
PASS. The consolidation removes ~120 lines of duplication, preserves
|
||||
behaviour across all three strategies (verified by 237 passing tests
|
||||
in the focused + adjacent suite), and respects the c1_vio component
|
||||
boundary. The cumulative review batches 52-54 Finding F1 is closed.
|
||||
|
||||
Risk 4 (mixin couples future divergence) is mitigated by the task
|
||||
spec's documented escape hatch: future divergent strategies can
|
||||
either override mixin methods or use the free functions in isolation.
|
||||
The 3 current strategies' state machines are byte-equivalent
|
||||
post-refactor, so the mixin is the right shape today.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Code Review Report — Batch 56
|
||||
|
||||
**Batch**: 56
|
||||
**Tasks**: AZ-335 (C1 Warm-Start Hint Persistence + F8 Reboot Recovery)
|
||||
**Date**: 2026-05-14
|
||||
**Verdict**: PASS_WITH_WARNINGS
|
||||
**Mode**: Full (per-batch)
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Result |
|
||||
|------------------------------------|----------|
|
||||
| 1. Context Loading | OK |
|
||||
| 2. Spec Compliance | OK (10/10 ACs implemented + tested; 3 NFRs covered) |
|
||||
| 3. Code Quality | OK |
|
||||
| 4. Security Quick-Scan | OK |
|
||||
| 5. Performance Scan | OK |
|
||||
| 6. Cross-Task Consistency | OK |
|
||||
| 7. Architecture Compliance | 1 Low note (F4) |
|
||||
|
||||
## Findings
|
||||
|
||||
| # | Severity | Category | File:Line | Title |
|
||||
|---|----------|-----------------|-----------|-------|
|
||||
| 1 | Low | Style | `runtime_root/warm_start_wiring.py:82` | AC-3 spec text says log kind `c1.warm_start.cold_start`; impl uses `c1.warm_start.cold_start_no_hint` |
|
||||
| 2 | Medium | Maintainability | `runtime_root/warm_start_wiring.py:267-272` | Per-frame save uses `VioOutput.relative_pose_T` directly as `WarmStartPose.body_T_world` without explicit baseline composition |
|
||||
| 3 | Low | Spec-Gap | `tests/unit/c1_vio/test_az335_warm_start.py:TestStoreNfrPerf` | NFR perf tests are dev-hardware smoke; full Tier-2 NVMe perf is deferred to C1-PT-01 |
|
||||
| 4 | Low | Architecture | `runtime_root/warm_start_wiring.py:54` | `runtime_root` imports c1-internal `_facade_spine` (`bias_norm`, `now_iso`) |
|
||||
|
||||
### Finding Details
|
||||
|
||||
**F1: AC-3 log kind shorthand vs source-suffixed kind** (Low / Style)
|
||||
|
||||
- Location: `src/gps_denied_onboard/runtime_root/warm_start_wiring.py:82`, mirrored in `_emit_prime_log` k-builder
|
||||
- Description: AZ-335 spec **AC-3** requires `INFO log kind="c1.warm_start.cold_start"`. The spec **Outcome §** also names the cold-start *source* tag as `cold_start_no_hint` (line 44 of `AZ-335_c1_warm_start_recovery.md`). The implementation builds the log kind as `f"c1.warm_start.{source}"` to keep the family namespace consistent (so all three sources — `f2_takeoff_fc`, `f8_reboot_disk`, `cold_start_no_hint` — produce log kinds that match their FDR `source` field). The result is `c1.warm_start.cold_start_no_hint`, which is more discriminating than the AC-3 shorthand but doesn't match it character-for-character.
|
||||
- Suggestion: Either (a) tighten AC-3's spec text in the next revision of `AZ-335_c1_warm_start_recovery.md` to say `c1.warm_start.cold_start_no_hint`, or (b) emit `c1.warm_start.cold_start` and keep the FDR record's `source` field as `cold_start_no_hint`. Option (a) preferred — the source-suffixed kind is genuinely more useful for log filtering.
|
||||
- Task: AZ-335
|
||||
|
||||
**F2: Per-frame save uses strategy-frame pose as `body_T_world`** (Medium / Maintainability)
|
||||
|
||||
- Location: `src/gps_denied_onboard/runtime_root/warm_start_wiring.py:267-272` (`_save_hint_from_output`)
|
||||
- Description: AZ-335 spec line 41 says "every emitted `VioOutput` from `process_frame` is converted into a `WarmStartPose` (relative-pose chained against the prior baseline by the runtime root, plus the latest `imu_bias` from the same `VioOutput`)". Per `_types.nav.VioOutput` docstring, `relative_pose_T` is "the strategy's current pose ... expressed in the strategy's own internal frame". The implementation passes `out.relative_pose_T` straight into `WarmStartPose.body_T_world` without composing against a takeoff baseline. This is **semantically defensible** because the strategy's "internal frame" persists across F8 reload: at F2 takeoff the FC EKF seeds the strategy's frame to world, and on F8 reload the saved hint reinstalls that same frame's most-recent pose so the strategy "continues from where it left off". But the spec phrasing implies an explicit baseline-compose step that the wiring layer would own. No AC tests this composition, so the gap is informational, not contractual.
|
||||
- Suggestion: Either (a) document the design choice inline in `_save_hint_from_output` (a 3-line comment explaining why the strategy-frame pose IS the right hint without explicit composition), or (b) revise the spec line 41 prose in cycle 2 to match the as-built behaviour. Recommend (a) — adds zero runtime cost, prevents future maintainers from "fixing" the gap.
|
||||
- Task: AZ-335
|
||||
|
||||
**F3: NFR perf tests are dev-hardware smoke** (Low / Spec-Gap)
|
||||
|
||||
- Location: `tests/unit/c1_vio/test_az335_warm_start.py::TestStoreNfrPerf`
|
||||
- Description: Spec NFR-perf-save (p99 ≤ 50 ms) and NFR-perf-load (p99 ≤ 20 ms) are explicitly Tier-2-NVMe budgets. The unit test uses 200 iterations on whatever filesystem `tmp_path` resolves to (developer hardware) and asserts the p99 is below the same threshold. This is sufficient to catch egregious regressions but is NOT the production NFR check.
|
||||
- Suggestion: Tier-2 measurement is the responsibility of `C1-PT-01` (Tier-2 perf gate; deferred to E-BBT). Keep the dev smoke as-is; do not expand here.
|
||||
- Task: AZ-335
|
||||
|
||||
**F4: `runtime_root` imports c1-internal `_facade_spine`** (Low / Architecture)
|
||||
|
||||
- Location: `src/gps_denied_onboard/runtime_root/warm_start_wiring.py:54`
|
||||
- Description: `runtime_root/warm_start_wiring.py` imports `bias_norm` and `now_iso` from `gps_denied_onboard.components.c1_vio._facade_spine`. Per `module-layout.md` §6 + §9, `runtime_root` is the composition root and may import any component's internal modules — so this is **allowed**. The note is recorded because importing an underscore-prefixed (c1-internal-by-convention) module from runtime_root is unusual: most runtime_root files only import each component's `interface.py` plus the concrete strategy modules.
|
||||
- Rationale for the choice: the AZ-335 wiring emits `vio.warm_start` FDR records that share the same `kind="vio.*"` namespace and timestamp/bias-norm conventions as the c1-strategy-internal `vio.health` records (AZ-528 / `_facade_spine`). Sharing the producer functions guarantees forensic logs across the family stay byte-identical in formatting. Inlining the two helpers in `warm_start_wiring.py` would introduce 6 lines of duplication and a future drift risk.
|
||||
- Suggestion: Keep the import. If a future cycle wants to formalize, promote `bias_norm` + `now_iso` into a shared helper module (e.g., `helpers/iso_timestamps.py` already exists for ISO-8601 handling per AZ-526; `bias_norm` could move to `helpers/imu_bias.py`).
|
||||
- Task: AZ-335
|
||||
|
||||
## Verdict logic
|
||||
|
||||
- 0 Critical, 0 High → **not FAIL**
|
||||
- 1 Medium + 3 Low → **PASS_WITH_WARNINGS**
|
||||
- All findings are non-blocking and documented for cycle-2 follow-up.
|
||||
|
||||
## Auto-fix Gate
|
||||
|
||||
Not applicable (no FAIL findings). All notes are informational / documentation-tightening.
|
||||
@@ -12,5 +12,6 @@ sub_step:
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 52
|
||||
last_cumulative_review: batches_49-51
|
||||
last_completed_batch: 56
|
||||
last_cumulative_review: batches_52-54
|
||||
current_batch: 57
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# D-CROSS-CVE-1 opencv-python pin deferred — gtsam/numpy ABI block
|
||||
|
||||
**Recorded**: 2026-05-11T02:55+03:00 (Europe/Kyiv)
|
||||
**Last replay attempt**: 2026-05-14T00:17+03:00 (Europe/Kyiv) — PyPI shows
|
||||
**Last replay attempt**: 2026-05-14T02:13+03:00 (Europe/Kyiv) — PyPI shows
|
||||
`gtsam==4.2.1` as the latest release; `requires_dist: numpy<2.0.0,>=1.11.0`.
|
||||
Replay condition (numpy>=2 wheels) still NOT met. Leftover remains open.
|
||||
**Status**: deferred-non-user (replay when upstream gtsam wheels target numpy>=2)
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
if(NOT BUILD_KLT_RANSAC)
|
||||
return()
|
||||
endif()
|
||||
message(STATUS "[klt_ransac] Placeholder; concrete sources land with AZ-334.")
|
||||
|
||||
# AZ-334 — KLT/RANSAC strategy is PURE PYTHON over OpenCV's Python
|
||||
# bindings (cv2.calcOpticalFlowPyrLK / goodFeaturesToTrack /
|
||||
# findEssentialMat / recoverPose). There is no native binding under
|
||||
# this strategy by design — the simple-baseline path must remain
|
||||
# dependency-light (Tier-0 workstation can run KLT/RANSAC with only
|
||||
# `pip install opencv-python`; no OKVIS2 / VINS-Mono native libs
|
||||
# required). This directory + CMake target is preserved for build-
|
||||
# graph symmetry with cpp/okvis2/ and cpp/vins_mono/; the BUILD_KLT_RANSAC
|
||||
# flag still gates the Python module import at the AZ-331 composition
|
||||
# root factory (`runtime_root/vio_factory.py`).
|
||||
message(STATUS "[klt_ransac] AZ-334 — pure-Python strategy; no native target. "
|
||||
"BUILD_KLT_RANSAC=ON gates the Python module import only.")
|
||||
|
||||
@@ -1,4 +1,86 @@
|
||||
# cpp/vins_mono/CMakeLists.txt — VINS-Mono wrapper for C1 VIO (AZ-333).
|
||||
#
|
||||
# Builds the de-ROSified VINS-Mono upstream pin (cpp/vins_mono/upstream/, git
|
||||
# submodule pointing at a community ROS-stripped fork OR an in-tree
|
||||
# ROS-strip applied at configure time) plus a pybind11 binding that
|
||||
# exposes the estimator to the Python facade at
|
||||
# src/gps_denied_onboard/components/c1_vio/vins_mono.py.
|
||||
#
|
||||
# Gating: BUILD_VINS_MONO=ON only on the IT-12 research binary
|
||||
# (research matrix kind in .github/workflows/ci.yml). Airborne /
|
||||
# operator-tooling / replay-cli builds default BUILD_VINS_MONO=OFF per
|
||||
# module-layout.md Build-Time Exclusion Map; CI's per-binary SBOM diff
|
||||
# (ci/sbom_diff.py) fails if `vins_mono` appears in any non-research
|
||||
# SBOM (Risk-3 mitigation).
|
||||
#
|
||||
# macOS dev builds default BUILD_VINS_MONO=OFF; unit tests use a fake
|
||||
# pybind11 binding fixture installed at sys.modules boundary
|
||||
# (tests/unit/c1_vio/conftest.py).
|
||||
#
|
||||
# Eigen / Ceres pinning: Risk-2 mitigation — the same Eigen pin is
|
||||
# linked from cpp/_third_party/eigen/ as cpp/okvis2/CMakeLists.txt to
|
||||
# avoid ABI mismatch when both load simultaneously inside the research
|
||||
# binary. Ceres is linked from system apt (libceres-dev) on Linux to
|
||||
# match VINS-Mono upstream's expected version surface.
|
||||
|
||||
if(NOT BUILD_VINS_MONO)
|
||||
return()
|
||||
endif()
|
||||
message(STATUS "[vins_mono] Placeholder; concrete sources land with AZ-333.")
|
||||
|
||||
message(STATUS "[vins_mono] BUILD_VINS_MONO=ON — building VINS-Mono upstream + pybind11 binding")
|
||||
|
||||
# Tell VINS-Mono upstream to skip its bundled ROS shim (the de-ROSified
|
||||
# port still ships a CMake hook that conditionally pulls roscpp; we keep
|
||||
# it OFF). Upstream-source modifications beyond ROS-stripping require an
|
||||
# explicit ADR addendum per task spec.
|
||||
set(VINS_MONO_USE_ROS OFF CACHE BOOL "AZ-333: ROS-strip — Risk-1 mitigation" FORCE)
|
||||
|
||||
# Trim upstream's build surface — we link the estimator + feature_tracker
|
||||
# only; demo apps / standalone runners are out.
|
||||
set(BUILD_VINS_APPS OFF CACHE BOOL "AZ-333: skip VINS-Mono demo apps" FORCE)
|
||||
set(BUILD_VINS_TESTS OFF CACHE BOOL "AZ-333: skip VINS-Mono gtests" FORCE)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "AZ-333: link VINS-Mono as static into the .so" FORCE)
|
||||
|
||||
# pybind11 (vendored at cpp/pybind11/upstream/) — guarded so a sibling
|
||||
# native binding (okvis2_binding, gtsam_bindings, faiss_index) cannot
|
||||
# double-add the subdirectory.
|
||||
if(NOT TARGET pybind11::module)
|
||||
add_subdirectory(
|
||||
${CMAKE_SOURCE_DIR}/cpp/pybind11/upstream
|
||||
${CMAKE_BINARY_DIR}/pybind11_build
|
||||
)
|
||||
endif()
|
||||
|
||||
# Vendored VINS-Mono upstream — EXCLUDE_FROM_ALL keeps unused targets
|
||||
# out of the default build graph; we depend on the vins_estimator /
|
||||
# feature_tracker libs we explicitly link below.
|
||||
add_subdirectory(upstream EXCLUDE_FROM_ALL)
|
||||
|
||||
# pybind11 binding source — per module-layout.md rule #4 the binding
|
||||
# code lives next to the Python facade, not under cpp/.
|
||||
set(VINS_MONO_BINDING_SRC
|
||||
${CMAKE_SOURCE_DIR}/src/gps_denied_onboard/components/c1_vio/_native/vins_mono_binding.cpp
|
||||
)
|
||||
|
||||
pybind11_add_module(vins_mono_binding ${VINS_MONO_BINDING_SRC})
|
||||
|
||||
# VINS-Mono export targets — exact list confirmed by walking upstream
|
||||
# CMakeLists in cpp/vins_mono/upstream/. If a target name changes
|
||||
# upstream, the linker error on first CI run pinpoints which one.
|
||||
target_link_libraries(vins_mono_binding
|
||||
PRIVATE
|
||||
vins_estimator
|
||||
feature_tracker
|
||||
camera_models
|
||||
ceres
|
||||
)
|
||||
|
||||
target_compile_features(vins_mono_binding PRIVATE cxx_std_17)
|
||||
|
||||
# Install the .so next to the Python facade so the lazy import inside
|
||||
# vins_mono.py (`from . import _native; _native.vins_mono_binding`)
|
||||
# resolves at runtime without a sys.path shim.
|
||||
install(TARGETS vins_mono_binding
|
||||
LIBRARY DESTINATION
|
||||
${CMAKE_INSTALL_LIBDIR}/gps_denied_onboard/components/c1_vio/_native/
|
||||
)
|
||||
|
||||
@@ -25,7 +25,12 @@ from gps_denied_onboard._types.nav import (
|
||||
VioState,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.config import C1VioConfig, Okvis2Config
|
||||
from gps_denied_onboard.components.c1_vio.config import (
|
||||
C1VioConfig,
|
||||
KltRansacConfig,
|
||||
Okvis2Config,
|
||||
VinsMonoConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.errors import (
|
||||
VioDegradedError,
|
||||
VioError,
|
||||
@@ -40,7 +45,9 @@ register_component_block("c1_vio", C1VioConfig)
|
||||
__all__ = [
|
||||
"C1VioConfig",
|
||||
"FeatureQuality",
|
||||
"KltRansacConfig",
|
||||
"Okvis2Config",
|
||||
"VinsMonoConfig",
|
||||
"VioDegradedError",
|
||||
"VioError",
|
||||
"VioFatalError",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""c1_vio strategy facade orchestration spine — c1-internal helpers (AZ-528).
|
||||
|
||||
Shared between :class:`Okvis2Strategy` (AZ-332),
|
||||
:class:`VinsMonoStrategy` (AZ-333), and :class:`KltRansacStrategy`
|
||||
(AZ-334). Closes cumulative review batches 52-54 Finding F1: the
|
||||
orchestration-spine duplication across the c1_vio strategy triplet.
|
||||
|
||||
Mirrors the AZ-527 precedent for the c2_vpr-side consolidation —
|
||||
underscore-prefixed module name keeps the helper c1-internal (NOT in
|
||||
``c1_vio/__init__.py``'s Public API surface); concrete strategies
|
||||
import it as a sibling. Engine output-shape contracts and VIO state
|
||||
machines are component-scoped concerns, NOT cross-component
|
||||
``shared/helpers/`` concerns.
|
||||
|
||||
The helper module exposes:
|
||||
|
||||
Free functions (stateless, pure):
|
||||
|
||||
- :func:`now_iso` — ISO-8601 UTC timestamp for FDR record ``ts``.
|
||||
- :func:`bias_norm` — L2 norm of the concatenated ``(accel || gyro)``
|
||||
6-vector.
|
||||
- :func:`se3_from_4x4` — lazy ``gtsam.Pose3`` construction from a
|
||||
4x4 row-major matrix.
|
||||
- :func:`frame_ts_ns` — UTC-epoch nanoseconds from
|
||||
:class:`NavCameraFrame` (OKVIS2 + VINS-Mono only; KLT/RANSAC owns
|
||||
inline grayscale conversion via its geometry pipeline).
|
||||
- :func:`frame_image` — contiguous ``uint8`` ndarray with 2D/3D
|
||||
shape validation. ``producer_id`` is parametrised so the error
|
||||
message names the originating strategy.
|
||||
|
||||
Mixin (:class:`FacadeSpine`) provides the state-machine + FDR
|
||||
``vio.health`` record emit. Concrete strategies inherit from it
|
||||
and set the per-instance attributes the mixin reads:
|
||||
|
||||
- ``_reported_state``, ``_frames_since_warmup``,
|
||||
``_warm_start_max_frames``, ``_feature_threshold`` — used by
|
||||
:meth:`FacadeSpine._classify_state`.
|
||||
- ``_consecutive_lost``, ``_lost_frame_threshold`` — used by
|
||||
:meth:`FacadeSpine._tick_lost`.
|
||||
- ``_last_emitted_state``, ``_producer_id``, ``_strategy_label``,
|
||||
``_latest_bias``, ``_fdr`` — used by
|
||||
:meth:`FacadeSpine._emit_transition`.
|
||||
|
||||
The mixin's required attributes are declared as class-level type
|
||||
annotations (no default values) so type-checkers / IDEs catch a
|
||||
strategy that forgets to set one in ``__init__`` — runtime safety
|
||||
is the AZ-528 AC-9 + AC-10 regression-guard tests that exercise
|
||||
the three concrete strategies end-to-end through the consolidated
|
||||
spine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.nav import (
|
||||
FeatureQuality,
|
||||
ImuBias,
|
||||
VioState,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.errors import VioFatalError
|
||||
from gps_denied_onboard.fdr_client.records import CURRENT_SCHEMA_VERSION, FdrRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy.typing as npt
|
||||
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FacadeSpine",
|
||||
"bias_norm",
|
||||
"frame_image",
|
||||
"frame_ts_ns",
|
||||
"now_iso",
|
||||
"se3_from_4x4",
|
||||
]
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""ISO-8601 UTC timestamp for FDR record ``ts``.
|
||||
|
||||
Returns the format used by all three c1_vio strategies' FDR
|
||||
record emissions (``+00:00`` offset suffix). This is NOT the
|
||||
``Z``-suffix canonical form used by
|
||||
:func:`gps_denied_onboard.helpers.iso_timestamps.iso_ts_from_clock`
|
||||
(AZ-526) — that helper serves clock-injected FDR timestamps
|
||||
from a :class:`~gps_denied_onboard.clock.Clock`, while this
|
||||
helper is the strategy-facade's own wall-clock stamp at state-
|
||||
transition emit time.
|
||||
"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def bias_norm(bias: ImuBias) -> float:
|
||||
"""L2 norm of the concatenated ``(accel_bias || gyro_bias)`` 6-vector."""
|
||||
accel = np.asarray(bias.accel_bias, dtype=np.float64)
|
||||
gyro = np.asarray(bias.gyro_bias, dtype=np.float64)
|
||||
return float(math.sqrt(float(np.dot(accel, accel) + np.dot(gyro, gyro))))
|
||||
|
||||
|
||||
def se3_from_4x4(matrix: npt.NDArray[Any]) -> Any:
|
||||
"""Build a ``gtsam.Pose3`` from a 4x4 row-major matrix.
|
||||
|
||||
Imported lazily so this module can be imported without gtsam in
|
||||
headless tooling paths (tests + facade-only smoke).
|
||||
"""
|
||||
import gtsam
|
||||
|
||||
return gtsam.Pose3(np.asarray(matrix, dtype=np.float64))
|
||||
|
||||
|
||||
def frame_ts_ns(frame: NavCameraFrame) -> int:
|
||||
"""Convert :attr:`NavCameraFrame.timestamp` to monotonic ns.
|
||||
|
||||
Uses the datetime's UTC epoch nanoseconds so the value is
|
||||
monotonically increasing across frames (frame source guarantees
|
||||
strictly increasing timestamps per the FrameSource contract).
|
||||
"""
|
||||
return int(frame.timestamp.timestamp() * 1e9)
|
||||
|
||||
|
||||
def frame_image(frame: NavCameraFrame, *, producer_id: str) -> np.ndarray:
|
||||
"""Coerce the frame's image into a contiguous ``uint8`` ndarray.
|
||||
|
||||
Raises :class:`VioFatalError` tagged with ``producer_id`` when
|
||||
the frame's image is not 2-D or 3-D.
|
||||
"""
|
||||
arr = np.ascontiguousarray(frame.image, dtype=np.uint8)
|
||||
if arr.ndim < 2 or arr.ndim > 3:
|
||||
raise VioFatalError(
|
||||
f"{producer_id}: NavCameraFrame.image must be 2-D or 3-D; got {arr.ndim}-D"
|
||||
)
|
||||
return arr
|
||||
|
||||
|
||||
class FacadeSpine:
|
||||
"""Orchestration-spine mixin for c1_vio :class:`VioStrategy` implementations.
|
||||
|
||||
Implements three state-machine helpers that were previously duplicated
|
||||
across :class:`Okvis2Strategy`, :class:`VinsMonoStrategy`, and
|
||||
:class:`KltRansacStrategy`:
|
||||
|
||||
- :meth:`_classify_state` — INIT during warmup, DEGRADED below
|
||||
the per-strategy feature threshold, TRACKING otherwise.
|
||||
- :meth:`_tick_lost` — increment the consecutive-lost counter,
|
||||
escalate to LOST at ``_lost_frame_threshold``, demote
|
||||
TRACKING → DEGRADED on the first lost frame.
|
||||
- :meth:`_emit_transition` — emit exactly one FDR ``vio.health``
|
||||
record per state change; no record on steady-state.
|
||||
|
||||
Concrete strategies MUST set the following attributes (typically
|
||||
in ``__init__``) before any of the mixin methods are called:
|
||||
|
||||
- ``_reported_state: VioState``
|
||||
- ``_frames_since_warmup: int``
|
||||
- ``_warm_start_max_frames: int``
|
||||
- ``_feature_threshold: int``
|
||||
- ``_consecutive_lost: int``
|
||||
- ``_lost_frame_threshold: int``
|
||||
- ``_last_emitted_state: VioState | None``
|
||||
- ``_producer_id: str`` — FDR record ``producer_id`` field
|
||||
(e.g. ``"c1_vio.okvis2"``).
|
||||
- ``_strategy_label: str`` — FDR payload ``strategy_label``
|
||||
field (e.g. ``"okvis2"``).
|
||||
- ``_latest_bias: ImuBias``
|
||||
- ``_fdr: FdrClient``
|
||||
|
||||
Type annotations on the class declare the attributes for static
|
||||
type-checkers; missing assignments at runtime surface as
|
||||
``AttributeError`` at the first state-machine call site.
|
||||
"""
|
||||
|
||||
_reported_state: VioState
|
||||
_frames_since_warmup: int
|
||||
_warm_start_max_frames: int
|
||||
_feature_threshold: int
|
||||
_consecutive_lost: int
|
||||
_lost_frame_threshold: int
|
||||
_last_emitted_state: VioState | None
|
||||
_producer_id: str
|
||||
_strategy_label: str
|
||||
_latest_bias: ImuBias
|
||||
_fdr: FdrClient
|
||||
|
||||
def _classify_state(self, fq: FeatureQuality) -> VioState:
|
||||
if self._reported_state == VioState.INIT and (
|
||||
self._frames_since_warmup + 1 < self._warm_start_max_frames
|
||||
):
|
||||
return VioState.INIT
|
||||
if fq.tracked < self._feature_threshold:
|
||||
return VioState.DEGRADED
|
||||
return VioState.TRACKING
|
||||
|
||||
def _tick_lost(self, frame_id: str) -> None:
|
||||
self._consecutive_lost += 1
|
||||
if self._consecutive_lost >= self._lost_frame_threshold:
|
||||
self._reported_state = VioState.LOST
|
||||
elif self._reported_state == VioState.TRACKING:
|
||||
self._reported_state = VioState.DEGRADED
|
||||
|
||||
def _emit_transition(self, new_state: VioState, frame_id: str) -> None:
|
||||
if self._last_emitted_state == new_state:
|
||||
return
|
||||
self._last_emitted_state = new_state
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=now_iso(),
|
||||
producer_id=self._producer_id,
|
||||
kind="vio.health",
|
||||
payload={
|
||||
"state": new_state.value,
|
||||
"consecutive_lost": self._consecutive_lost,
|
||||
"bias_norm": bias_norm(self._latest_bias),
|
||||
"strategy_label": self._strategy_label,
|
||||
"frame_id": frame_id,
|
||||
},
|
||||
)
|
||||
self._fdr.enqueue(record)
|
||||
@@ -0,0 +1,338 @@
|
||||
// AZ-333 — pybind11 binding for VINS-Mono (research-only C1 VIO).
|
||||
//
|
||||
// Exposes a narrow surface that mirrors what the Python facade
|
||||
// (`gps_denied_onboard.components.c1_vio.vins_mono.VinsMonoStrategy`)
|
||||
// needs — NOT the full VINS-Mono estimator API. The surface mirrors
|
||||
// the AZ-332 OKVIS2 binding 1:1 so the AZ-331 factory can treat both
|
||||
// strategies as drop-in substitutable for the IT-12 comparative-study
|
||||
// research binary:
|
||||
//
|
||||
// VinsMonoBackend
|
||||
// ctor(yaml_config: str, camera_intrinsics_3x3: ndarray[float64, 3, 3])
|
||||
// add_frame(frame_id: str, ts_ns: int, image: ndarray[uint8, H, W, C]) -> bool
|
||||
// add_imu(ts_ns: int, accel: ndarray[float64, 3], gyro: ndarray[float64, 3]) -> None
|
||||
// get_latest_output() -> dict | None
|
||||
// reset(body_T_world: ndarray[float64, 4, 4], velocity: ndarray[float64, 3],
|
||||
// accel_bias: ndarray[float64, 3], gyro_bias: ndarray[float64, 3]) -> None
|
||||
// health() -> dict
|
||||
//
|
||||
// Frame buffers cross the FFI boundary as `py::array_t<uint8_t,
|
||||
// c_style|forcecast>` so the camera-ingest path (AZ-265
|
||||
// LiveCameraFrameSource) can hand off a contiguous numpy array without
|
||||
// a copy — Risk-2 mitigation per the AZ-333 task spec.
|
||||
//
|
||||
// Exception envelope: every VINS-Mono / Ceres / Eigen / std::runtime_error
|
||||
// inside a binding method is caught and rethrown as one of three
|
||||
// Python-side exceptions registered via `py::register_exception`. The
|
||||
// Python facade then rewraps those into the VioError family.
|
||||
//
|
||||
// Risk-1 mitigation (ROS leak): this binding compiles against the
|
||||
// de-ROSified VINS-Mono pin only — `cpp/vins_mono/CMakeLists.txt`
|
||||
// strips upstream's `roscpp` / `rosbag` deps before the
|
||||
// `vins_estimator` core is exposed here.
|
||||
|
||||
#include <pybind11/pybind11.h>
|
||||
#include <pybind11/numpy.h>
|
||||
#include <pybind11/stl.h>
|
||||
|
||||
#include <Eigen/Core>
|
||||
#include <Eigen/Geometry>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
// VINS-Mono estimator headers. The exact include path is determined by
|
||||
// the de-ROSified upstream pin's CMake export. The skeleton compiles
|
||||
// without these headers because the actual `vins_estimator::Estimator`
|
||||
// wiring lives in _build_estimator() / _drive_estimator(), which today
|
||||
// STUB and surface a runtime error if invoked. Wiring them in is the
|
||||
// follow-up task within AZ-333's tier2 deliverable bundle.
|
||||
//
|
||||
// #include <vins_estimator/estimator.h>
|
||||
// #include <vins_estimator/feature_tracker.h>
|
||||
// #include <vins_estimator/parameters.h>
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exception types — registered as Python-side classes via
|
||||
// `py::register_exception` in PYBIND11_MODULE below. The Python facade
|
||||
// catches these and rewraps into the VioError family.
|
||||
|
||||
class VinsMonoInitException : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
class VinsMonoFatalException : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
class VinsMonoOptimizationException : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pose / output struct produced by the estimator step.
|
||||
struct EstimatorOutput {
|
||||
std::string frame_id;
|
||||
Eigen::Matrix4d pose_T_world_body;
|
||||
Eigen::Matrix<double, 6, 6> pose_covariance_6x6;
|
||||
Eigen::Vector3d accel_bias;
|
||||
Eigen::Vector3d gyro_bias;
|
||||
int tracked_features = 0;
|
||||
int new_features = 0;
|
||||
int lost_features = 0;
|
||||
double mean_parallax = 0.0;
|
||||
double mre_px = 0.0;
|
||||
std::int64_t emitted_at_ns = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal estimator state machine — INIT until the SfM bootstrap
|
||||
// converges (VINS-Mono's `solve_initial` flips), TRACKING during nominal
|
||||
// optimisation, DEGRADED on feature-count drop, LOST after consecutive
|
||||
// failed updates.
|
||||
enum class HealthState : int { Init = 0, Tracking = 1, Degraded = 2, Lost = 3 };
|
||||
|
||||
const char* state_to_str(HealthState s) {
|
||||
switch (s) {
|
||||
case HealthState::Init:
|
||||
return "init";
|
||||
case HealthState::Tracking:
|
||||
return "tracking";
|
||||
case HealthState::Degraded:
|
||||
return "degraded";
|
||||
case HealthState::Lost:
|
||||
return "lost";
|
||||
}
|
||||
return "init";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VinsMonoBackend — the C++ surface exposed to Python.
|
||||
class VinsMonoBackend {
|
||||
public:
|
||||
VinsMonoBackend(const std::string& yaml_config,
|
||||
py::array_t<double, py::array::c_style | py::array::forcecast>
|
||||
camera_intrinsics_3x3)
|
||||
: yaml_config_(yaml_config) {
|
||||
if (camera_intrinsics_3x3.ndim() != 2 ||
|
||||
camera_intrinsics_3x3.shape(0) != 3 ||
|
||||
camera_intrinsics_3x3.shape(1) != 3) {
|
||||
throw VinsMonoInitException(
|
||||
"VinsMonoBackend: camera_intrinsics_3x3 must be a 3x3 float64 array");
|
||||
}
|
||||
auto buf = camera_intrinsics_3x3.unchecked<2>();
|
||||
for (py::ssize_t i = 0; i < 3; ++i) {
|
||||
for (py::ssize_t j = 0; j < 3; ++j) {
|
||||
K_(i, j) = buf(i, j);
|
||||
}
|
||||
}
|
||||
_build_estimator();
|
||||
}
|
||||
|
||||
// Push a nav-camera frame into the estimator.
|
||||
// Returns true if the estimator produced a new output for this frame
|
||||
// (caller then calls `get_latest_output()`); false if the frame was
|
||||
// consumed but did not yield a new output (e.g. dropped as
|
||||
// non-keyframe by the parallax-driven keyframe selector).
|
||||
bool add_frame(
|
||||
const std::string& frame_id, std::int64_t ts_ns,
|
||||
py::array_t<std::uint8_t,
|
||||
py::array::c_style | py::array::forcecast> image) {
|
||||
if (image.ndim() < 2 || image.ndim() > 3) {
|
||||
throw VinsMonoOptimizationException(
|
||||
"VinsMonoBackend.add_frame: image must be 2-D (grayscale) or 3-D "
|
||||
"(HxWxC)");
|
||||
}
|
||||
pending_frame_id_ = frame_id;
|
||||
pending_ts_ns_ = ts_ns;
|
||||
return _drive_estimator(image);
|
||||
}
|
||||
|
||||
void add_imu(std::int64_t ts_ns,
|
||||
py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> accel,
|
||||
py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> gyro) {
|
||||
if (accel.size() != 3 || gyro.size() != 3) {
|
||||
throw VinsMonoOptimizationException(
|
||||
"VinsMonoBackend.add_imu: accel and gyro must be length-3 float64 "
|
||||
"arrays");
|
||||
}
|
||||
if (ts_ns <= last_imu_ts_ns_) {
|
||||
throw VinsMonoOptimizationException(
|
||||
"VinsMonoBackend.add_imu: ts_ns must be strict-monotonic");
|
||||
}
|
||||
last_imu_ts_ns_ = ts_ns;
|
||||
// Real VINS-Mono IMU push lands here once the estimator is wired
|
||||
// in. For the skeleton we just record the most recent sample — the
|
||||
// estimator's IMU pre-integration is performed inside
|
||||
// `vins_estimator::Estimator::processIMU`.
|
||||
auto a = accel.unchecked<1>();
|
||||
auto g = gyro.unchecked<1>();
|
||||
last_accel_ = Eigen::Vector3d(a(0), a(1), a(2));
|
||||
last_gyro_ = Eigen::Vector3d(g(0), g(1), g(2));
|
||||
}
|
||||
|
||||
std::optional<py::dict> get_latest_output() const {
|
||||
std::lock_guard<std::mutex> lk(output_mtx_);
|
||||
if (!latest_output_.has_value()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto& o = *latest_output_;
|
||||
py::dict d;
|
||||
d["frame_id"] = o.frame_id;
|
||||
d["pose_T_world_body"] = py::array_t<double>(
|
||||
{4, 4}, {sizeof(double) * 4, sizeof(double)},
|
||||
o.pose_T_world_body.data());
|
||||
d["pose_covariance_6x6"] = py::array_t<double>(
|
||||
{6, 6}, {sizeof(double) * 6, sizeof(double)},
|
||||
o.pose_covariance_6x6.data());
|
||||
d["accel_bias"] = py::array_t<double>(
|
||||
{3}, {sizeof(double)}, o.accel_bias.data());
|
||||
d["gyro_bias"] = py::array_t<double>(
|
||||
{3}, {sizeof(double)}, o.gyro_bias.data());
|
||||
d["tracked_features"] = o.tracked_features;
|
||||
d["new_features"] = o.new_features;
|
||||
d["lost_features"] = o.lost_features;
|
||||
d["mean_parallax"] = o.mean_parallax;
|
||||
d["mre_px"] = o.mre_px;
|
||||
d["emitted_at_ns"] = o.emitted_at_ns;
|
||||
return d;
|
||||
}
|
||||
|
||||
void reset(py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> body_T_world,
|
||||
py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> velocity,
|
||||
py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> accel_bias,
|
||||
py::array_t<double,
|
||||
py::array::c_style | py::array::forcecast> gyro_bias) {
|
||||
if (body_T_world.ndim() != 2 || body_T_world.shape(0) != 4 ||
|
||||
body_T_world.shape(1) != 4) {
|
||||
throw VinsMonoInitException(
|
||||
"VinsMonoBackend.reset: body_T_world must be a 4x4 float64 array");
|
||||
}
|
||||
if (velocity.size() != 3 || accel_bias.size() != 3 ||
|
||||
gyro_bias.size() != 3) {
|
||||
throw VinsMonoInitException(
|
||||
"VinsMonoBackend.reset: velocity / *_bias must be length-3 float64 "
|
||||
"arrays");
|
||||
}
|
||||
auto T = body_T_world.unchecked<2>();
|
||||
for (py::ssize_t i = 0; i < 4; ++i) {
|
||||
for (py::ssize_t j = 0; j < 4; ++j) {
|
||||
seed_body_T_world_(i, j) = T(i, j);
|
||||
}
|
||||
}
|
||||
auto v = velocity.unchecked<1>();
|
||||
auto ab = accel_bias.unchecked<1>();
|
||||
auto gb = gyro_bias.unchecked<1>();
|
||||
seed_velocity_ = Eigen::Vector3d(v(0), v(1), v(2));
|
||||
seed_accel_bias_ = Eigen::Vector3d(ab(0), ab(1), ab(2));
|
||||
seed_gyro_bias_ = Eigen::Vector3d(gb(0), gb(1), gb(2));
|
||||
|
||||
state_ = HealthState::Init;
|
||||
consecutive_lost_ = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mtx_);
|
||||
latest_output_.reset();
|
||||
}
|
||||
_build_estimator();
|
||||
}
|
||||
|
||||
py::dict health() const {
|
||||
py::dict d;
|
||||
d["state"] = std::string(state_to_str(state_));
|
||||
d["consecutive_lost"] = consecutive_lost_;
|
||||
d["bias_norm"] = std::sqrt(
|
||||
seed_accel_bias_.squaredNorm() + seed_gyro_bias_.squaredNorm());
|
||||
return d;
|
||||
}
|
||||
|
||||
private:
|
||||
void _build_estimator() {
|
||||
// Real wiring: instantiate `vins_estimator::Estimator` from
|
||||
// `yaml_config_`, attach the output callback that fills
|
||||
// `latest_output_` under `output_mtx_` whenever
|
||||
// `processMeasurements` produces a new sliding-window solution.
|
||||
//
|
||||
// The skeleton intentionally throws on any actual frame ingest so a
|
||||
// research binary that loads this binding before AZ-333's estimator
|
||||
// wiring lands cannot silently report misleading poses.
|
||||
estimator_built_ = false;
|
||||
}
|
||||
|
||||
bool _drive_estimator(
|
||||
py::array_t<std::uint8_t,
|
||||
py::array::c_style | py::array::forcecast> /*image*/) {
|
||||
if (!estimator_built_) {
|
||||
// Skeleton path — pybind11 binding compiles and loads but the
|
||||
// VINS-Mono estimator is not yet wired. Tier-2 follow-up wires it.
|
||||
throw VinsMonoFatalException(
|
||||
"VinsMonoBackend: VINS-Mono estimator not yet wired — this "
|
||||
"binding is the AZ-333 skeleton; tier2 follow-up wires "
|
||||
"vins_estimator::Estimator + feature_tracker");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string yaml_config_;
|
||||
Eigen::Matrix3d K_ = Eigen::Matrix3d::Identity();
|
||||
Eigen::Matrix4d seed_body_T_world_ = Eigen::Matrix4d::Identity();
|
||||
Eigen::Vector3d seed_velocity_ = Eigen::Vector3d::Zero();
|
||||
Eigen::Vector3d seed_accel_bias_ = Eigen::Vector3d::Zero();
|
||||
Eigen::Vector3d seed_gyro_bias_ = Eigen::Vector3d::Zero();
|
||||
Eigen::Vector3d last_accel_ = Eigen::Vector3d::Zero();
|
||||
Eigen::Vector3d last_gyro_ = Eigen::Vector3d::Zero();
|
||||
|
||||
HealthState state_ = HealthState::Init;
|
||||
int consecutive_lost_ = 0;
|
||||
std::int64_t last_imu_ts_ns_ = -1;
|
||||
std::string pending_frame_id_;
|
||||
std::int64_t pending_ts_ns_ = 0;
|
||||
bool estimator_built_ = false;
|
||||
|
||||
mutable std::mutex output_mtx_;
|
||||
std::optional<EstimatorOutput> latest_output_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
PYBIND11_MODULE(vins_mono_binding, m) {
|
||||
m.doc() =
|
||||
"VINS-Mono pybind11 binding (AZ-333). Wraps the de-ROSified VINS-Mono "
|
||||
"estimator core for the Python VinsMonoStrategy facade. Tier2 follow-up "
|
||||
"wires the real estimator. Research-only — not present in airborne / "
|
||||
"operator-tooling / replay-cli binaries (BUILD_VINS_MONO=OFF).";
|
||||
|
||||
py::register_exception<VinsMonoInitException>(m, "VinsMonoInitException");
|
||||
py::register_exception<VinsMonoFatalException>(m, "VinsMonoFatalException");
|
||||
py::register_exception<VinsMonoOptimizationException>(
|
||||
m, "VinsMonoOptimizationException");
|
||||
|
||||
py::class_<VinsMonoBackend>(m, "VinsMonoBackend")
|
||||
.def(py::init<const std::string&,
|
||||
py::array_t<double, py::array::c_style | py::array::forcecast>>(),
|
||||
py::arg("yaml_config"), py::arg("camera_intrinsics_3x3"))
|
||||
.def("add_frame", &VinsMonoBackend::add_frame, py::arg("frame_id"),
|
||||
py::arg("ts_ns"), py::arg("image"))
|
||||
.def("add_imu", &VinsMonoBackend::add_imu, py::arg("ts_ns"),
|
||||
py::arg("accel"), py::arg("gyro"))
|
||||
.def("get_latest_output", &VinsMonoBackend::get_latest_output)
|
||||
.def("reset", &VinsMonoBackend::reset, py::arg("body_T_world"),
|
||||
py::arg("velocity"), py::arg("accel_bias"), py::arg("gyro_bias"))
|
||||
.def("health", &VinsMonoBackend::health);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""C1 VIO strategy config block (AZ-331 + AZ-332).
|
||||
"""C1 VIO strategy config block (AZ-331 + AZ-332 + AZ-333 + AZ-334).
|
||||
|
||||
Registered into ``config.components['c1_vio']`` by the package
|
||||
``__init__.py``. The composition-root factory
|
||||
@@ -11,6 +11,19 @@ carrying OKVIS2-specific knobs (sliding-window size, parallax-driven
|
||||
keyframe threshold, RANSAC inlier ratio, max optimisation iterations,
|
||||
degraded-feature threshold, per-frame debug log). Only consulted when
|
||||
``strategy == "okvis2"``.
|
||||
|
||||
AZ-333 extends with a sibling :class:`VinsMonoConfig` for the
|
||||
research-only VINS-Mono backend (sliding-window size, feature tracker
|
||||
thresholds, marginalisation strategy, max optimisation iterations,
|
||||
degraded-feature threshold, per-frame debug log). Only consulted when
|
||||
``strategy == "vins_mono"``.
|
||||
|
||||
AZ-334 extends with a sibling :class:`KltRansacConfig` for the
|
||||
mandatory simple-baseline pure-Python OpenCV KLT/RANSAC backend
|
||||
(max corners, KLT pyramid levels, KLT window size, essential-matrix
|
||||
RANSAC threshold, RANSAC inlier ratio for the AZ-282 helper stage,
|
||||
min features for pose, per-frame debug log). Only consulted when
|
||||
``strategy == "klt_ransac"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +36,9 @@ from gps_denied_onboard.config.schema import ConfigError
|
||||
__all__ = [
|
||||
"KNOWN_STRATEGIES",
|
||||
"C1VioConfig",
|
||||
"KltRansacConfig",
|
||||
"Okvis2Config",
|
||||
"VinsMonoConfig",
|
||||
]
|
||||
|
||||
KNOWN_STRATEGIES: Final[frozenset[str]] = frozenset({"okvis2", "vins_mono", "klt_ransac"})
|
||||
@@ -88,6 +103,162 @@ class Okvis2Config:
|
||||
)
|
||||
|
||||
|
||||
_ALLOWED_VINS_MARGINALISATION: Final[frozenset[str]] = frozenset(
|
||||
{"old", "second_new"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VinsMonoConfig:
|
||||
"""VINS-Mono-specific knobs (AZ-333; research-only backend).
|
||||
|
||||
``sliding_window_size`` is the VINS-Mono optimisation-window size
|
||||
in keyframes — must be in [10, 20] mirroring D-C5-3's K bound.
|
||||
|
||||
``feature_min_tracked`` is the per-frame tracked-feature floor
|
||||
below which the frontend declares the frame untrackable; default
|
||||
20 (VINS-Mono ``MIN_DIST`` upstream default surface).
|
||||
|
||||
``feature_min_parallax_px`` is the parallax-driven keyframe
|
||||
selection threshold; default 10.0 px (VINS-Mono upstream default
|
||||
for 752×480 EuRoC-class fixtures).
|
||||
|
||||
``marginalisation_strategy`` selects ``"old"`` (drop the oldest
|
||||
keyframe and marginalise its prior into the Hessian) or
|
||||
``"second_new"`` (drop the second-newest, used when the newest is
|
||||
a non-keyframe). Both are upstream-supported.
|
||||
|
||||
``max_optimization_iters`` caps the per-frame Ceres solver
|
||||
iterations; default 8 (VINS-Mono upstream default; higher than
|
||||
OKVIS2 because Ceres single-iteration cost is lower).
|
||||
|
||||
``degraded_feature_threshold`` is the per-frame tracked-feature
|
||||
count below which ``health_snapshot`` reports DEGRADED; default 30
|
||||
(matches ``Okvis2Config`` so cross-strategy comparison is fair).
|
||||
|
||||
``per_frame_debug_log`` enables a DEBUG log line per
|
||||
``process_frame`` — OFF by default.
|
||||
"""
|
||||
|
||||
sliding_window_size: int = 10
|
||||
feature_min_tracked: int = 20
|
||||
feature_min_parallax_px: float = 10.0
|
||||
marginalisation_strategy: str = "old"
|
||||
max_optimization_iters: int = 8
|
||||
degraded_feature_threshold: int = 30
|
||||
per_frame_debug_log: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not (10 <= self.sliding_window_size <= 20):
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.sliding_window_size must be in [10, 20] "
|
||||
f"(D-C5-3 budget); got {self.sliding_window_size}"
|
||||
)
|
||||
if self.feature_min_tracked < 1:
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.feature_min_tracked must be >= 1; "
|
||||
f"got {self.feature_min_tracked}"
|
||||
)
|
||||
if self.feature_min_parallax_px <= 0.0:
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.feature_min_parallax_px must be > 0; "
|
||||
f"got {self.feature_min_parallax_px}"
|
||||
)
|
||||
if self.marginalisation_strategy not in _ALLOWED_VINS_MARGINALISATION:
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.marginalisation_strategy must be one of "
|
||||
f"{sorted(_ALLOWED_VINS_MARGINALISATION)}; "
|
||||
f"got {self.marginalisation_strategy!r}"
|
||||
)
|
||||
if self.max_optimization_iters < 1:
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.max_optimization_iters must be >= 1; "
|
||||
f"got {self.max_optimization_iters}"
|
||||
)
|
||||
if self.degraded_feature_threshold < 1:
|
||||
raise ConfigError(
|
||||
"VinsMonoConfig.degraded_feature_threshold must be >= 1; "
|
||||
f"got {self.degraded_feature_threshold}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KltRansacConfig:
|
||||
"""KLT/RANSAC-specific knobs (AZ-334; mandatory simple-baseline).
|
||||
|
||||
``max_corners`` is the per-frame upper bound on features extracted
|
||||
by ``cv2.goodFeaturesToTrack``; default 200 (OpenCV documentation
|
||||
suggests 100-500 for visual-odometry use).
|
||||
|
||||
``klt_window_size_px`` is the per-level search window edge length
|
||||
(square) passed to ``cv2.calcOpticalFlowPyrLK``; default 21 (the
|
||||
OpenCV cookbook default for moderately-fast UAV motion).
|
||||
|
||||
``klt_pyramid_levels`` is the number of pyramid levels in the
|
||||
pyramidal Lucas-Kanade tracker; default 3 (covers ~8x scale span
|
||||
per level so 3 levels handle typical UAV inter-frame motion).
|
||||
|
||||
``min_features_for_pose`` is the inlier-count floor below which
|
||||
``health_snapshot`` reports DEGRADED; pose recovery is still
|
||||
attempted but the per-frame covariance is inflated. Default 30,
|
||||
matching ``Okvis2Config.degraded_feature_threshold`` so the
|
||||
cross-strategy DEGRADED gate is consistent.
|
||||
|
||||
``ransac_inlier_ratio`` is the inlier ratio threshold the
|
||||
AZ-282 :class:`RansacFilter` stage uses to reject correspondences
|
||||
BEFORE the essential-matrix recovery stage; default 0.5. Surfaced
|
||||
here because the helper itself is stateless.
|
||||
|
||||
``essential_matrix_ransac_threshold_px`` is the per-pixel
|
||||
reprojection-error threshold passed to ``cv2.findEssentialMat``'s
|
||||
internal RANSAC; default 1.0 px in normalised image coordinates
|
||||
(OpenCV's documented default for forward-looking cameras).
|
||||
|
||||
``per_frame_debug_log`` enables a DEBUG log line per
|
||||
``process_frame`` — OFF by default (would flood at 3 Hz steady-state).
|
||||
"""
|
||||
|
||||
max_corners: int = 200
|
||||
klt_window_size_px: int = 21
|
||||
klt_pyramid_levels: int = 3
|
||||
min_features_for_pose: int = 30
|
||||
ransac_inlier_ratio: float = 0.5
|
||||
essential_matrix_ransac_threshold_px: float = 1.0
|
||||
per_frame_debug_log: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_corners < 4:
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.max_corners must be >= 4 (essential matrix "
|
||||
f"requires >=5 correspondences); got {self.max_corners}"
|
||||
)
|
||||
if self.klt_window_size_px < 3 or self.klt_window_size_px % 2 == 0:
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.klt_window_size_px must be an odd integer "
|
||||
f">= 3; got {self.klt_window_size_px}"
|
||||
)
|
||||
if self.klt_pyramid_levels < 1:
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.klt_pyramid_levels must be >= 1; "
|
||||
f"got {self.klt_pyramid_levels}"
|
||||
)
|
||||
if self.min_features_for_pose < 5:
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.min_features_for_pose must be >= 5 (essential "
|
||||
f"matrix DOF floor); got {self.min_features_for_pose}"
|
||||
)
|
||||
if not (0.0 < self.ransac_inlier_ratio <= 1.0):
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.ransac_inlier_ratio must be in (0.0, 1.0]; "
|
||||
f"got {self.ransac_inlier_ratio}"
|
||||
)
|
||||
if self.essential_matrix_ransac_threshold_px <= 0.0:
|
||||
raise ConfigError(
|
||||
"KltRansacConfig.essential_matrix_ransac_threshold_px must be "
|
||||
f"> 0; got {self.essential_matrix_ransac_threshold_px}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C1VioConfig:
|
||||
"""Per-component config for C1 VIO.
|
||||
@@ -102,16 +273,47 @@ class C1VioConfig:
|
||||
default 9 per ``vio_strategy_protocol.md`` v1.0.0.
|
||||
|
||||
``warm_start_max_frames`` is the convergence budget after
|
||||
:meth:`VioStrategy.reset_to_warm_start`; default 5.
|
||||
:meth:`VioStrategy.reset_to_warm_start`; default 5. The same
|
||||
integer also drives the AZ-335 post-reset covariance-inflation
|
||||
window (the runtime root inflates the strategy's emitted
|
||||
covariance for exactly this many frames after every
|
||||
``reset_to_warm_start``).
|
||||
|
||||
``warm_start_store_dir`` is the on-disk directory the AZ-335
|
||||
warm-start hint store writes ``c1_warm_start.json`` into. Default
|
||||
``/var/lib/gps_denied_onboard/warm_start/``. The operator's systemd
|
||||
unit MUST point this at a writable mount on the airborne deployment.
|
||||
|
||||
``warm_start_save_period_frames`` throttles the per-frame
|
||||
save hook — the wiring saves the hint only every Nth successful
|
||||
``VioOutput`` to bound disk I/O at the 3 Hz frame rate. Default 5
|
||||
(≈ 0.6 Hz).
|
||||
|
||||
``post_reset_covariance_inflation_factor`` multiplies the
|
||||
strategy's emitted ``pose_covariance_6x6`` for the first
|
||||
``warm_start_max_frames`` frames after every ``reset_to_warm_start``;
|
||||
enforced at the wiring layer to defend AC-5.3's "no fake confidence"
|
||||
invariant. Default 2.0; must be > 1.0 (1.0 would defeat AC-8).
|
||||
|
||||
``okvis2`` carries OKVIS2-specific knobs (AZ-332); consulted only
|
||||
when ``strategy == "okvis2"``.
|
||||
|
||||
``vins_mono`` carries VINS-Mono-specific knobs (AZ-333); consulted
|
||||
only when ``strategy == "vins_mono"``.
|
||||
|
||||
``klt_ransac`` carries KLT/RANSAC-specific knobs (AZ-334);
|
||||
consulted only when ``strategy == "klt_ransac"``.
|
||||
"""
|
||||
|
||||
strategy: str = "klt_ransac"
|
||||
lost_frame_threshold: int = 9
|
||||
warm_start_max_frames: int = 5
|
||||
warm_start_store_dir: str = "/var/lib/gps_denied_onboard/warm_start/"
|
||||
warm_start_save_period_frames: int = 5
|
||||
post_reset_covariance_inflation_factor: float = 2.0
|
||||
okvis2: Okvis2Config = field(default_factory=Okvis2Config)
|
||||
vins_mono: VinsMonoConfig = field(default_factory=VinsMonoConfig)
|
||||
klt_ransac: KltRansacConfig = field(default_factory=KltRansacConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.strategy not in KNOWN_STRATEGIES:
|
||||
@@ -126,3 +328,19 @@ class C1VioConfig:
|
||||
raise ConfigError(
|
||||
f"C1VioConfig.warm_start_max_frames must be >= 1; got {self.warm_start_max_frames}"
|
||||
)
|
||||
if not self.warm_start_store_dir:
|
||||
raise ConfigError(
|
||||
"C1VioConfig.warm_start_store_dir must be a non-empty path; "
|
||||
f"got {self.warm_start_store_dir!r}"
|
||||
)
|
||||
if self.warm_start_save_period_frames < 1:
|
||||
raise ConfigError(
|
||||
"C1VioConfig.warm_start_save_period_frames must be >= 1; "
|
||||
f"got {self.warm_start_save_period_frames}"
|
||||
)
|
||||
if self.post_reset_covariance_inflation_factor <= 1.0:
|
||||
raise ConfigError(
|
||||
"C1VioConfig.post_reset_covariance_inflation_factor must be > 1.0 "
|
||||
"(1.0 would defeat AC-5.3's 'no fake confidence' floor); "
|
||||
f"got {self.post_reset_covariance_inflation_factor}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
"""`KltRansacStrategy` — mandatory simple-baseline C1 VIO (AZ-334).
|
||||
|
||||
Pure-Python facade over OpenCV's pyramidal Lucas-Kanade optical-flow +
|
||||
essential-matrix RANSAC path. The ADR-002 engine-rule mandatory
|
||||
baseline: every airborne binary MUST link a simple-baseline strategy
|
||||
alongside the production-default. KLT/RANSAC is the lowest-complexity
|
||||
strategy in E-C1 by code volume — no C++/pybind11, no native binding —
|
||||
so a Tier-0 workstation can run it with only ``pip install opencv-python``
|
||||
and the AZ-331 factory's ``BUILD_KLT_RANSAC=ON`` gate.
|
||||
|
||||
Conforms to the AZ-331 :class:`VioStrategy` Protocol; consumes the
|
||||
runtime ``Config`` + an :class:`FdrClient`; constructs its other
|
||||
dependencies (logger, KLT/RANSAC sub-config, IMU preintegrator) from
|
||||
``config`` so the strategy class matches the composition-root factory
|
||||
shape::
|
||||
|
||||
strategy_cls(config: Config, *, fdr_client: FdrClient)
|
||||
|
||||
This mirrors :class:`Okvis2Strategy` (AZ-332) + :class:`VinsMonoStrategy`
|
||||
(AZ-333) deliberately: the AZ-331 factory produces all three via the
|
||||
same ``(config, *, fdr_client)`` shape and the IT-12 comparative-study
|
||||
harness expects them to be drop-in substitutable. The structural
|
||||
duplication of the constructor / state machine / error-rewrap ladder
|
||||
is tracked for the post-AZ-334 hygiene PBI (Batch 53 review F1).
|
||||
|
||||
AC mapping (see ``_docs/02_tasks/done/AZ-334_c1_klt_ransac_strategy.md``):
|
||||
|
||||
- AC-1 : :meth:`current_strategy_label` returns ``"klt_ransac"``.
|
||||
- AC-2 : First :meth:`process_frame` emits :class:`VioOutput` with
|
||||
identity relative pose, conservative INIT-state covariance, and
|
||||
``health_snapshot().state == INIT``.
|
||||
- AC-3 : Steady-state :meth:`process_frame` emits :class:`VioOutput`
|
||||
with non-identity relative pose, SPD covariance, ``mre_px > 0``.
|
||||
- AC-4 : ``cv2.error`` from :func:`cv2.findEssentialMat` /
|
||||
:func:`cv2.recoverPose` rewraps into :class:`VioFatalError` with a
|
||||
``__cause__`` chain; no raw ``cv2.error`` leaks.
|
||||
- AC-5 : :meth:`reset_to_warm_start` clears the feature buffer +
|
||||
re-seeds the IMU bias via the AZ-276 preintegrator's
|
||||
:meth:`reset_with_bias`; idempotent across consecutive calls.
|
||||
- AC-6 : Inlier loss → :class:`VioState.DEGRADED` + monotonically
|
||||
growing covariance Frobenius norm; :class:`VioOutput` IS emitted
|
||||
(not raised).
|
||||
- AC-7 : ``lost_frame_threshold`` consecutive failed-pose frames →
|
||||
:class:`VioFatalError`; ``health_snapshot().state == LOST``.
|
||||
- AC-8 : ``BUILD_KLT_RANSAC=OFF`` does not import this module —
|
||||
enforced by AZ-331's factory in
|
||||
:mod:`gps_denied_onboard.runtime_root.vio_factory`;
|
||||
``StrategyNotAvailableError`` is the surfaced error.
|
||||
- AC-9 : Honest covariance — no shrinkage during DEGRADED; the
|
||||
per-frame covariance is the residual-scatter formula divided by
|
||||
the inlier-DOF (``N_inliers - 5``) with no client-side floor or
|
||||
smoother.
|
||||
- AC-10: Exactly one ``vio.health`` FDR record per state transition;
|
||||
no spam on steady-state.
|
||||
- AC-11: Camera-agnostic source — no ``adti20`` / ``adti26`` literals;
|
||||
the per-call :class:`CameraCalibration` argument carries intrinsics.
|
||||
|
||||
Risk mitigations (see task spec for full text):
|
||||
|
||||
- *Risk 1 — residual-scatter under-reports during high-overlap straight
|
||||
flight*: documented; the C1-IT-12 comparative-study report cross-
|
||||
validates against OKVIS2. No code mitigation in this strategy.
|
||||
- *Risk 2 — KLT track loss on first frame*: AC-2 handles INIT state +
|
||||
FDR record on transition.
|
||||
- *Risk 3 — RANSAC threshold sensitivity*: surfaced via
|
||||
``KltRansacConfig.essential_matrix_ransac_threshold_px``; the
|
||||
AZ-282 :class:`RansacFilter` pre-filter runs at the
|
||||
``essential_matrix_ransac_threshold_px`` boundary, then
|
||||
:func:`cv2.findEssentialMat`'s internal RANSAC runs again on the
|
||||
surviving inlier set — two stages with separate determinism gates.
|
||||
- *Risk 4 — RESTRICT-UAV-3 sharp turns*: DEGRADED reported immediately;
|
||||
recovery is F6 satellite re-localisation (E-C2 / E-C3 / E-C4 path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.nav import (
|
||||
FeatureQuality,
|
||||
ImuBias,
|
||||
VioHealth,
|
||||
VioOutput,
|
||||
VioState,
|
||||
)
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c1_vio._facade_spine import (
|
||||
FacadeSpine,
|
||||
bias_norm,
|
||||
se3_from_4x4,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.errors import (
|
||||
VioFatalError,
|
||||
VioInitializingError,
|
||||
)
|
||||
from gps_denied_onboard.helpers.imu_preintegrator import (
|
||||
ImuPreintegrationError,
|
||||
ImuPreintegrator,
|
||||
make_imu_preintegrator,
|
||||
)
|
||||
from gps_denied_onboard.helpers.ransac_filter import RansacFilter, RansacFilterError
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy.typing as npt
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import (
|
||||
ImuWindow,
|
||||
NavCameraFrame,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c1_vio.config import KltRansacConfig
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
|
||||
__all__ = ["KltRansacStrategy"]
|
||||
|
||||
|
||||
_STRATEGY_LABEL: Final[Literal["klt_ransac"]] = "klt_ransac"
|
||||
_PRODUCER_ID: Final[str] = "c1_vio.klt_ransac"
|
||||
_LOGGER_COMPONENT: Final[str] = "c1_vio.klt_ransac"
|
||||
# Essential matrix has 5 degrees of freedom (E in R^{3x3} with rank-2 +
|
||||
# scale ambiguity); residual-scatter covariance DOF = N_inliers - 5.
|
||||
_ESSENTIAL_MATRIX_DOF: Final[int] = 5
|
||||
# INIT-state conservative covariance scalar applied uniformly to the
|
||||
# 6x6 identity. Larger than typical TRACKING-state covariance so C5
|
||||
# fusion treats the first-frame pose as effectively un-informed
|
||||
# (relative pose is identity anyway). Documented limit, not derived.
|
||||
_INIT_STATE_COVARIANCE_SCALAR: Final[float] = 10.0
|
||||
|
||||
|
||||
def _grayscale(image: npt.NDArray[Any]) -> npt.NDArray[Any]:
|
||||
"""Coerce a NavCameraFrame image to OpenCV's expected 2D ``uint8``.
|
||||
|
||||
NavCameraFrame.image is permitted to be 2-D (already grayscale)
|
||||
or 3-D (HxWx{1,3,4}); OpenCV's KLT path expects 2-D uint8. Color
|
||||
images are routed through ``cv2.cvtColor`` so this strategy works
|
||||
against both monochrome industrial cameras AND the standard
|
||||
BGR-coded test fixtures.
|
||||
"""
|
||||
arr = np.ascontiguousarray(image)
|
||||
if arr.dtype != np.uint8:
|
||||
# Convert any non-uint8 type via clipping + cast. OpenCV's KLT
|
||||
# internals only accept uint8; silently routing floats through
|
||||
# would hide a calibration bug.
|
||||
if np.issubdtype(arr.dtype, np.floating):
|
||||
arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8)
|
||||
else:
|
||||
arr = arr.astype(np.uint8)
|
||||
if arr.ndim == 2:
|
||||
return arr
|
||||
if arr.ndim == 3:
|
||||
channels = arr.shape[2]
|
||||
if channels == 1:
|
||||
return arr.reshape(arr.shape[0], arr.shape[1])
|
||||
if channels in (3, 4):
|
||||
code = cv2.COLOR_BGR2GRAY if channels == 3 else cv2.COLOR_BGRA2GRAY
|
||||
return cv2.cvtColor(arr, code)
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: NavCameraFrame.image has unsupported shape "
|
||||
f"{arr.shape}; expected 2-D or 3-D with 1/3/4 channels."
|
||||
)
|
||||
|
||||
|
||||
def _intrinsics_3x3(calibration: CameraCalibration) -> np.ndarray:
|
||||
"""Pull the 3x3 intrinsics matrix from a CameraCalibration DTO.
|
||||
|
||||
The DTO stores ``intrinsics_3x3`` as ``Any`` so any list / tuple /
|
||||
ndarray that coerces to (3, 3) is accepted. Anything else fails
|
||||
BEFORE OpenCV would surface a less-actionable ``cv2.error``.
|
||||
"""
|
||||
K = np.asarray(calibration.intrinsics_3x3, dtype=np.float64)
|
||||
if K.shape != (3, 3):
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: CameraCalibration.intrinsics_3x3 must be "
|
||||
f"3x3; got shape {K.shape}"
|
||||
)
|
||||
return K
|
||||
|
||||
|
||||
class KltRansacStrategy(FacadeSpine):
|
||||
"""Mandatory simple-baseline :class:`VioStrategy` for E-C1 (AZ-334).
|
||||
|
||||
Constructor matches the AZ-331 composition-root factory shape::
|
||||
|
||||
KltRansacStrategy(config: Config, *, fdr_client: FdrClient)
|
||||
|
||||
Other dependencies (KLT/RANSAC sub-config, logger, IMU preintegrator)
|
||||
are resolved internally from ``config`` and the per-call
|
||||
:class:`CameraCalibration`. The preintegrator is lazily constructed
|
||||
on the first :meth:`process_frame` call (it requires the calibration
|
||||
to read the IMU noise model).
|
||||
|
||||
Concurrency: single-threaded by Protocol invariant. One instance
|
||||
per camera-ingest writer thread; concurrent ``process_frame`` calls
|
||||
are undefined behaviour.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
*,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
c1_block = config.components["c1_vio"]
|
||||
if c1_block.strategy != _STRATEGY_LABEL:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy constructed with config.strategy="
|
||||
f"{c1_block.strategy!r}; expected {_STRATEGY_LABEL!r}. "
|
||||
"The AZ-331 factory is the only sanctioned constructor."
|
||||
)
|
||||
|
||||
self._config = config
|
||||
self._fdr = fdr_client
|
||||
self._clock: Clock = clock if clock is not None else WallClock()
|
||||
self._logger = get_logger(_LOGGER_COMPONENT)
|
||||
self._lost_frame_threshold: int = c1_block.lost_frame_threshold
|
||||
self._warm_start_max_frames: int = c1_block.warm_start_max_frames
|
||||
self._cfg: KltRansacConfig = c1_block.klt_ransac
|
||||
self._feature_threshold: int = self._cfg.min_features_for_pose
|
||||
self._producer_id: str = _PRODUCER_ID
|
||||
self._strategy_label: str = _STRATEGY_LABEL
|
||||
|
||||
# Per-frame state.
|
||||
self._prev_gray: np.ndarray | None = None
|
||||
self._prev_features: np.ndarray | None = None
|
||||
self._calibration: CameraCalibration | None = None
|
||||
self._preintegrator: ImuPreintegrator | None = None
|
||||
self._frames_since_warmup: int = 0
|
||||
self._consecutive_lost: int = 0
|
||||
self._latest_bias: ImuBias = ImuBias(
|
||||
accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)
|
||||
)
|
||||
self._reported_state: VioState = VioState.INIT
|
||||
self._last_emitted_state: VioState | None = None
|
||||
# Last frame's covariance Frobenius norm — used to verify the
|
||||
# honest-covariance monotonicity invariant in DEGRADED operation.
|
||||
# NOT a covariance floor (AC-9 forbids one); this is a read-only
|
||||
# diagnostic checked at the end of process_frame.
|
||||
self._last_cov_frobenius: float = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public Protocol surface.
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
imu: ImuWindow,
|
||||
calibration: CameraCalibration,
|
||||
) -> VioOutput:
|
||||
"""Hot-path call — one per nav-camera frame.
|
||||
|
||||
Steps (see task spec § Outcome for the canonical narrative):
|
||||
|
||||
1. Push every IMU sample in the window through the AZ-276
|
||||
preintegrator (strict-monotonic guard lives in the helper).
|
||||
2. Convert the frame to grayscale ``uint8``.
|
||||
3. First-frame path: seed features + return identity-pose
|
||||
``VioOutput`` with INIT state.
|
||||
4. Subsequent-frame path: KLT-track the prior features, run
|
||||
the AZ-282 :class:`RansacFilter` inlier rejection, recover
|
||||
the essential-matrix + pose, build the ``VioOutput`` with
|
||||
residual-scatter covariance.
|
||||
"""
|
||||
self._calibration = calibration
|
||||
frame_id_str = str(frame.frame_id)
|
||||
emitted_at_ns = self._clock.monotonic_ns()
|
||||
|
||||
# 1. Push IMU samples — bias propagation only; KLT itself is
|
||||
# vision-only so the latest_bias stays equal to the most recent
|
||||
# warm-start hint until the C5 estimator pushes a fresh value
|
||||
# through `reset_to_warm_start`.
|
||||
self._ensure_preintegrator(calibration)
|
||||
try:
|
||||
assert self._preintegrator is not None # narrow for mypy
|
||||
self._preintegrator.integrate_window(imu)
|
||||
except ImuPreintegrationError as exc:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: IMU preintegrator rejected window at "
|
||||
f"{frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 2. Grayscale.
|
||||
try:
|
||||
curr_gray = _grayscale(frame.image)
|
||||
except cv2.error as exc:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: OpenCV failed to grayscale frame "
|
||||
f"{frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
# 3. First-frame seed + INIT emit.
|
||||
if self._prev_gray is None or self._prev_features is None:
|
||||
self._seed_features(curr_gray, frame_id_str)
|
||||
self._prev_gray = curr_gray
|
||||
return self._first_frame_output(frame_id_str, emitted_at_ns)
|
||||
|
||||
# 4. KLT track features into current frame.
|
||||
try:
|
||||
tracked = self._track_features(self._prev_gray, curr_gray, self._prev_features)
|
||||
except cv2.error as exc:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: OpenCV KLT track failed at {frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
prior_feature_count = int(self._prev_features.shape[0])
|
||||
|
||||
# 4.5 Floor check — essential matrix requires >=5 correspondences.
|
||||
if tracked.shape[0] < _ESSENTIAL_MATRIX_DOF:
|
||||
return self._pose_recovery_failed(
|
||||
frame_id_str,
|
||||
emitted_at_ns,
|
||||
prior_feature_count=prior_feature_count,
|
||||
reason="insufficient_tracked_features",
|
||||
)
|
||||
|
||||
# 5. AZ-282 RANSAC pre-filter — separate stage from the
|
||||
# findEssentialMat internal RANSAC.
|
||||
try:
|
||||
ransac_result = RansacFilter.filter_correspondences(
|
||||
tracked,
|
||||
self._cfg.essential_matrix_ransac_threshold_px,
|
||||
int(self._cfg.min_features_for_pose * self._cfg.ransac_inlier_ratio),
|
||||
)
|
||||
except RansacFilterError as exc:
|
||||
# Helper rejected the input (degenerate correspondences,
|
||||
# OpenCV internal failure). Treat as pose-recovery failure.
|
||||
return self._pose_recovery_failed(
|
||||
frame_id_str,
|
||||
emitted_at_ns,
|
||||
prior_feature_count=prior_feature_count,
|
||||
reason=f"ransac_filter_error: {exc}",
|
||||
)
|
||||
|
||||
if ransac_result.inlier_count < _ESSENTIAL_MATRIX_DOF:
|
||||
return self._pose_recovery_failed(
|
||||
frame_id_str,
|
||||
emitted_at_ns,
|
||||
prior_feature_count=prior_feature_count,
|
||||
reason="insufficient_inliers_after_ransac",
|
||||
)
|
||||
|
||||
# 6. Essential-matrix + recoverPose.
|
||||
K = _intrinsics_3x3(calibration)
|
||||
inliers = ransac_result.inlier_correspondences
|
||||
pts_prev = inliers[:, :2].astype(np.float64, copy=False)
|
||||
pts_curr = inliers[:, 2:].astype(np.float64, copy=False)
|
||||
try:
|
||||
E, em_mask = cv2.findEssentialMat(
|
||||
pts_prev,
|
||||
pts_curr,
|
||||
K,
|
||||
method=cv2.RANSAC,
|
||||
threshold=float(self._cfg.essential_matrix_ransac_threshold_px),
|
||||
)
|
||||
if E is None or np.asarray(E).shape != (3, 3):
|
||||
return self._pose_recovery_failed(
|
||||
frame_id_str,
|
||||
emitted_at_ns,
|
||||
prior_feature_count=prior_feature_count,
|
||||
reason="find_essential_mat_no_model",
|
||||
)
|
||||
_retval, R, t, _final_mask = cv2.recoverPose(E, pts_prev, pts_curr, K, mask=em_mask)
|
||||
except cv2.error as exc:
|
||||
# AC-4: rewrap raw cv2.error as VioFatalError.
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: OpenCV essential-matrix / recoverPose "
|
||||
f"failed at {frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
if R is None or t is None:
|
||||
return self._pose_recovery_failed(
|
||||
frame_id_str,
|
||||
emitted_at_ns,
|
||||
prior_feature_count=prior_feature_count,
|
||||
reason="recover_pose_no_solution",
|
||||
)
|
||||
|
||||
# 7. Build SE(3) from (R, t).
|
||||
pose_4x4 = np.eye(4, dtype=np.float64)
|
||||
pose_4x4[:3, :3] = np.asarray(R, dtype=np.float64)
|
||||
pose_4x4[:3, 3] = np.asarray(t, dtype=np.float64).flatten()
|
||||
pose = se3_from_4x4(pose_4x4)
|
||||
|
||||
# 8. Final inlier count for state classification + covariance.
|
||||
final_inlier_count = (
|
||||
int(np.count_nonzero(em_mask)) if em_mask is not None else ransac_result.inlier_count
|
||||
)
|
||||
if final_inlier_count < _ESSENTIAL_MATRIX_DOF:
|
||||
final_inlier_count = ransac_result.inlier_count
|
||||
|
||||
# 9. Estimate covariance from the AZ-282 median residual +
|
||||
# inlier-count penalty. Honest-covariance invariant (AC-9): no
|
||||
# client-side floor; the formula is residual_var / DOF + small
|
||||
# inlier-count term so the cov grows monotonically as inliers
|
||||
# drop or residuals scatter.
|
||||
cov = self._estimate_covariance(
|
||||
median_residual_px=ransac_result.median_residual_px,
|
||||
inlier_count=final_inlier_count,
|
||||
)
|
||||
|
||||
# 10. Build VioOutput.
|
||||
fq = FeatureQuality(
|
||||
tracked=int(final_inlier_count),
|
||||
new=int(max(0, prior_feature_count - tracked.shape[0])),
|
||||
lost=int(max(0, prior_feature_count - final_inlier_count)),
|
||||
mean_parallax=_safe_float(ransac_result.median_residual_px),
|
||||
mre_px=_safe_float(ransac_result.median_residual_px),
|
||||
)
|
||||
|
||||
self._latest_bias = self._latest_bias # unchanged — KLT is vision-only
|
||||
vio_output = VioOutput(
|
||||
frame_id=frame_id_str,
|
||||
relative_pose_T=pose,
|
||||
pose_covariance_6x6=cov,
|
||||
imu_bias=self._latest_bias,
|
||||
feature_quality=fq,
|
||||
emitted_at_ns=emitted_at_ns,
|
||||
)
|
||||
|
||||
# 11. Success path — reset lost counter, classify state.
|
||||
self._consecutive_lost = 0
|
||||
new_state = self._classify_state(fq)
|
||||
if new_state != self._reported_state:
|
||||
self._reported_state = new_state
|
||||
self._emit_transition(new_state, frame_id_str)
|
||||
|
||||
if new_state in (VioState.INIT, VioState.TRACKING):
|
||||
self._frames_since_warmup += 1
|
||||
|
||||
# 12. Re-seed features for next frame — KLT/RANSAC re-detects
|
||||
# corners every frame to keep the feature count bounded and to
|
||||
# mitigate the well-known KLT drift-away problem on long tracks.
|
||||
try:
|
||||
self._seed_features(curr_gray, frame_id_str)
|
||||
except cv2.error as exc:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: OpenCV goodFeaturesToTrack failed "
|
||||
f"at {frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
self._prev_gray = curr_gray
|
||||
self._last_cov_frobenius = float(np.linalg.norm(cov, ord="fro"))
|
||||
|
||||
if self._cfg.per_frame_debug_log:
|
||||
self._logger.debug(
|
||||
"klt_ransac.process_frame",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "vio.tick",
|
||||
"frame_id": frame_id_str,
|
||||
"kv": {
|
||||
"state": self._reported_state.value,
|
||||
"tracked": fq.tracked,
|
||||
"mre_px": fq.mre_px,
|
||||
"cov_frobenius": self._last_cov_frobenius,
|
||||
"emitted_at_ns": vio_output.emitted_at_ns,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return vio_output
|
||||
|
||||
def reset_to_warm_start(self, hint: WarmStartPose) -> None:
|
||||
"""Destructive re-init from an F8-reboot warm-start hint.
|
||||
|
||||
Clears the prior-frame KLT buffer + re-seeds the IMU bias via
|
||||
the AZ-276 preintegrator. Idempotent across consecutive calls
|
||||
(AC-4) — a second call without an intervening
|
||||
:meth:`process_frame` reseats state without raising.
|
||||
"""
|
||||
try:
|
||||
_ = np.asarray(hint.body_T_world.matrix(), dtype=np.float64)
|
||||
except AttributeError as exc:
|
||||
raise VioFatalError(
|
||||
"KltRansacStrategy.reset_to_warm_start: hint.body_T_world is "
|
||||
"not a gtsam.Pose3 (missing .matrix())"
|
||||
) from exc
|
||||
|
||||
# Seed the bias on the preintegrator IF it has been constructed;
|
||||
# if `process_frame` has never been called yet, the
|
||||
# preintegrator does not exist (it needs the per-call
|
||||
# calibration). The hint bias is still recorded so the FIRST
|
||||
# `process_frame` (which builds the preintegrator) starts with
|
||||
# the right value via `reset_with_bias` after construction.
|
||||
if self._preintegrator is not None:
|
||||
try:
|
||||
self._preintegrator.reset_with_bias(hint.bias)
|
||||
except ImuPreintegrationError as exc:
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: preintegrator rejected warm-start "
|
||||
f"bias reset: {exc}"
|
||||
) from exc
|
||||
|
||||
self._latest_bias = hint.bias
|
||||
self._prev_gray = None
|
||||
self._prev_features = None
|
||||
self._frames_since_warmup = 0
|
||||
self._consecutive_lost = 0
|
||||
self._reported_state = VioState.INIT
|
||||
self._last_cov_frobenius = 0.0
|
||||
self._emit_transition(VioState.INIT, frame_id="")
|
||||
|
||||
def health_snapshot(self) -> VioHealth:
|
||||
"""Most-recent health state — no OpenCV call (cheap)."""
|
||||
return VioHealth(
|
||||
state=self._reported_state,
|
||||
consecutive_lost=self._consecutive_lost,
|
||||
bias_norm=bias_norm(self._latest_bias),
|
||||
)
|
||||
|
||||
def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
|
||||
return _STRATEGY_LABEL
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers.
|
||||
|
||||
def _ensure_preintegrator(self, calibration: CameraCalibration) -> None:
|
||||
"""Build the AZ-276 preintegrator on the first frame.
|
||||
|
||||
The preintegrator needs the per-deployment IMU noise model from
|
||||
``calibration.metadata``; that's only available once the camera-
|
||||
ingest loop has the first ``NavCameraFrame``. Subsequent frames
|
||||
reuse the same instance.
|
||||
"""
|
||||
if self._preintegrator is None:
|
||||
self._preintegrator = make_imu_preintegrator(calibration)
|
||||
# Seed bias if a warm-start hint was applied before the
|
||||
# first frame (the hint cannot reach the preintegrator
|
||||
# earlier because the preintegrator does not exist yet).
|
||||
if (
|
||||
self._latest_bias.accel_bias != (0.0, 0.0, 0.0)
|
||||
or self._latest_bias.gyro_bias != (0.0, 0.0, 0.0)
|
||||
):
|
||||
self._preintegrator.reset_with_bias(self._latest_bias)
|
||||
|
||||
def _seed_features(self, gray: np.ndarray, frame_id_str: str) -> None:
|
||||
"""Detect fresh corners + store as the prior-frame feature buffer."""
|
||||
features = cv2.goodFeaturesToTrack(
|
||||
gray,
|
||||
maxCorners=int(self._cfg.max_corners),
|
||||
qualityLevel=0.01,
|
||||
minDistance=7,
|
||||
)
|
||||
if features is None:
|
||||
# Empty detection — record an empty buffer so the next
|
||||
# `process_frame` enters the "insufficient_tracked_features"
|
||||
# branch and ticks the lost counter.
|
||||
self._prev_features = np.empty((0, 1, 2), dtype=np.float32)
|
||||
else:
|
||||
self._prev_features = np.asarray(features, dtype=np.float32)
|
||||
|
||||
def _track_features(
|
||||
self,
|
||||
prev_gray: np.ndarray,
|
||||
curr_gray: np.ndarray,
|
||||
prev_features: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Run pyramidal Lucas-Kanade + return surviving (N, 4) correspondences."""
|
||||
if prev_features.shape[0] == 0:
|
||||
return np.empty((0, 4), dtype=np.float64)
|
||||
|
||||
win = int(self._cfg.klt_window_size_px)
|
||||
new_features, status, _err = cv2.calcOpticalFlowPyrLK(
|
||||
prev_gray,
|
||||
curr_gray,
|
||||
prev_features,
|
||||
None,
|
||||
winSize=(win, win),
|
||||
maxLevel=int(self._cfg.klt_pyramid_levels - 1),
|
||||
)
|
||||
if new_features is None or status is None:
|
||||
return np.empty((0, 4), dtype=np.float64)
|
||||
|
||||
status_flat = status.flatten().astype(bool)
|
||||
if not np.any(status_flat):
|
||||
return np.empty((0, 4), dtype=np.float64)
|
||||
|
||||
prev_pts = prev_features.reshape(-1, 2)[status_flat]
|
||||
curr_pts = np.asarray(new_features).reshape(-1, 2)[status_flat]
|
||||
correspondences = np.hstack(
|
||||
[prev_pts.astype(np.float64, copy=False), curr_pts.astype(np.float64, copy=False)]
|
||||
)
|
||||
return correspondences
|
||||
|
||||
def _first_frame_output(self, frame_id_str: str, emitted_at_ns: int) -> VioOutput:
|
||||
"""Return identity-pose + INIT-state ``VioOutput`` for AC-2.
|
||||
|
||||
Identity SE(3) is the canonical "no motion yet" pose; the
|
||||
covariance is intentionally large so C5 fusion treats this
|
||||
as un-informed (the warm-start hint, not this VioOutput, is
|
||||
what seeds C5's initial estimate).
|
||||
"""
|
||||
identity_pose = se3_from_4x4(np.eye(4, dtype=np.float64))
|
||||
cov = np.eye(6, dtype=np.float64) * _INIT_STATE_COVARIANCE_SCALAR
|
||||
fq = FeatureQuality(
|
||||
tracked=int(self._prev_features.shape[0]) if self._prev_features is not None else 0,
|
||||
new=int(self._prev_features.shape[0]) if self._prev_features is not None else 0,
|
||||
lost=0,
|
||||
mean_parallax=0.0,
|
||||
mre_px=0.0,
|
||||
)
|
||||
self._frames_since_warmup += 1
|
||||
# Emit the INIT transition exactly once.
|
||||
self._emit_transition(VioState.INIT, frame_id_str)
|
||||
self._last_cov_frobenius = float(np.linalg.norm(cov, ord="fro"))
|
||||
return VioOutput(
|
||||
frame_id=frame_id_str,
|
||||
relative_pose_T=identity_pose,
|
||||
pose_covariance_6x6=cov,
|
||||
imu_bias=self._latest_bias,
|
||||
feature_quality=fq,
|
||||
emitted_at_ns=emitted_at_ns,
|
||||
)
|
||||
|
||||
def _pose_recovery_failed(
|
||||
self,
|
||||
frame_id_str: str,
|
||||
emitted_at_ns: int,
|
||||
*,
|
||||
prior_feature_count: int,
|
||||
reason: str,
|
||||
) -> VioOutput:
|
||||
"""Pose-recovery failure path.
|
||||
|
||||
Ticks the lost counter and either raises (per AC-7) or returns
|
||||
a DEGRADED ``VioOutput`` with inflated covariance. The choice
|
||||
between raise vs. return mirrors :class:`Okvis2Strategy` /
|
||||
:class:`VinsMonoStrategy`: after the lost-frame threshold is
|
||||
exceeded, raise :class:`VioFatalError`; otherwise raise
|
||||
:class:`VioInitializingError`.
|
||||
|
||||
AC-6's "VioOutput IS emitted" path is the OTHER branch (low
|
||||
inliers but pose DID recover) — that path never reaches this
|
||||
helper. This helper is reserved for the AC-7 failed-pose
|
||||
regime.
|
||||
"""
|
||||
self._tick_lost(frame_id_str)
|
||||
if self._reported_state == VioState.LOST:
|
||||
self._emit_transition(VioState.LOST, frame_id_str)
|
||||
raise VioFatalError(
|
||||
f"KltRansacStrategy: exhausted lost-frame budget "
|
||||
f"({self._lost_frame_threshold} consecutive failures) at "
|
||||
f"{frame_id_str!r} ({reason})"
|
||||
)
|
||||
self._emit_transition(self._reported_state, frame_id_str)
|
||||
raise VioInitializingError(
|
||||
f"KltRansacStrategy: pose recovery failed at {frame_id_str!r} "
|
||||
f"({reason}); prior feature count = {prior_feature_count}"
|
||||
)
|
||||
|
||||
def _estimate_covariance(
|
||||
self,
|
||||
*,
|
||||
median_residual_px: float,
|
||||
inlier_count: int,
|
||||
) -> np.ndarray:
|
||||
"""Honest covariance estimator — see AZ-334 § Outcome step 7.
|
||||
|
||||
Standard textbook approach: ``sigma^2 / DOF`` gives the
|
||||
per-parameter variance, where ``DOF = N_inliers - 5`` (essential
|
||||
matrix has 5 DOF). The ``inlier_count`` penalty term ensures
|
||||
cov Frobenius is strictly monotonic non-decreasing as inliers
|
||||
drop — required by AC-6 + AC-9.
|
||||
|
||||
Returned as a 6x6 diagonal matrix. SPD by construction. The
|
||||
diagonal form is a simplification — it ignores the directional
|
||||
sensitivity of pose error to image residuals (Risk-1). The
|
||||
Step 9 IT-12 comparative report cross-validates against
|
||||
OKVIS2's full block covariance.
|
||||
|
||||
Honest-covariance invariant (AC-9): NO client-side floor or
|
||||
smoother. The formula is deterministic in its inputs.
|
||||
"""
|
||||
# NaN-safe residual sigma — RansacFilter returns NaN for empty
|
||||
# inlier sets; clip to a small positive value so the math stays
|
||||
# well-defined (the caller path guarantees inlier_count >= 5
|
||||
# before reaching this helper, so the NaN branch is defensive).
|
||||
sigma_sq = (
|
||||
float(median_residual_px) ** 2
|
||||
if median_residual_px == median_residual_px and median_residual_px > 0.0
|
||||
else 1e-6
|
||||
)
|
||||
dof = max(inlier_count - _ESSENTIAL_MATRIX_DOF, 1)
|
||||
# Inlier-count penalty: monotonically increasing as inlier_count
|
||||
# drops. The coefficient is tied to the RANSAC threshold so the
|
||||
# penalty is in the same pixel-residual units as sigma_sq.
|
||||
inlier_penalty = (
|
||||
float(self._cfg.essential_matrix_ransac_threshold_px)
|
||||
/ max(int(inlier_count), 1)
|
||||
)
|
||||
scalar = (sigma_sq + inlier_penalty) / dof
|
||||
return np.eye(6, dtype=np.float64) * scalar
|
||||
|
||||
|
||||
def _safe_float(value: float) -> float:
|
||||
"""NaN-safe float coercion — RansacFilter returns NaN for empty inliers."""
|
||||
if value != value: # NaN check without numpy
|
||||
return 0.0
|
||||
return float(value)
|
||||
@@ -44,8 +44,6 @@ AC mapping (see ``_docs/02_tasks/todo/AZ-332_c1_okvis2_strategy.md``):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
@@ -58,16 +56,20 @@ from gps_denied_onboard._types.nav import (
|
||||
VioState,
|
||||
)
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c1_vio._facade_spine import (
|
||||
FacadeSpine,
|
||||
bias_norm,
|
||||
frame_image,
|
||||
frame_ts_ns,
|
||||
se3_from_4x4,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.errors import (
|
||||
VioFatalError,
|
||||
VioInitializingError,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.records import CURRENT_SCHEMA_VERSION, FdrRecord
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy.typing as npt
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import (
|
||||
ImuWindow,
|
||||
@@ -85,32 +87,9 @@ __all__ = ["Okvis2Strategy"]
|
||||
_STRATEGY_LABEL: Final[Literal["okvis2"]] = "okvis2"
|
||||
_PRODUCER_ID: Final[str] = "c1_vio.okvis2"
|
||||
_LOGGER_COMPONENT: Final[str] = "c1_vio.okvis2"
|
||||
_BIAS_NORM_FLOOR: Final[float] = 0.0
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _bias_norm(bias: ImuBias) -> float:
|
||||
"""L2 norm of the concatenated 6-vector ``(accel || gyro)``."""
|
||||
accel = np.asarray(bias.accel_bias, dtype=np.float64)
|
||||
gyro = np.asarray(bias.gyro_bias, dtype=np.float64)
|
||||
return float(math.sqrt(float(np.dot(accel, accel) + np.dot(gyro, gyro))))
|
||||
|
||||
|
||||
def _se3_from_4x4(matrix: npt.NDArray[Any]) -> Any:
|
||||
"""Build a ``gtsam.Pose3`` from a 4x4 row-major matrix.
|
||||
|
||||
Imported lazily so this module can be imported without gtsam in
|
||||
headless tooling paths (tests + facade-only smoke).
|
||||
"""
|
||||
import gtsam
|
||||
|
||||
return gtsam.Pose3(np.asarray(matrix, dtype=np.float64))
|
||||
|
||||
|
||||
class Okvis2Strategy:
|
||||
class Okvis2Strategy(FacadeSpine):
|
||||
"""Production-default :class:`VioStrategy` for E-C1 (AZ-332).
|
||||
|
||||
Constructor matches the AZ-331 composition-root factory shape::
|
||||
@@ -147,6 +126,9 @@ class Okvis2Strategy:
|
||||
self._lost_frame_threshold: int = c1_block.lost_frame_threshold
|
||||
self._warm_start_max_frames: int = c1_block.warm_start_max_frames
|
||||
self._okvis2_cfg: Okvis2Config = c1_block.okvis2
|
||||
self._feature_threshold: int = self._okvis2_cfg.degraded_feature_threshold
|
||||
self._producer_id: str = _PRODUCER_ID
|
||||
self._strategy_label: str = _STRATEGY_LABEL
|
||||
self._calibration: CameraCalibration | None = None
|
||||
self._frames_since_warmup: int = 0
|
||||
self._consecutive_lost: int = 0
|
||||
@@ -202,7 +184,9 @@ class Okvis2Strategy:
|
||||
try:
|
||||
self._push_imu_window(imu)
|
||||
produced = self._backend.add_frame(
|
||||
frame_id_str, _frame_ts_ns(frame), _frame_image(frame)
|
||||
frame_id_str,
|
||||
frame_ts_ns(frame),
|
||||
frame_image(frame, producer_id="Okvis2Strategy"),
|
||||
)
|
||||
except self._binding_module.OkvisInitException as exc:
|
||||
self._emit_transition(VioState.INIT, frame_id_str)
|
||||
@@ -319,7 +303,7 @@ class Okvis2Strategy:
|
||||
return VioHealth(
|
||||
state=self._reported_state,
|
||||
consecutive_lost=self._consecutive_lost,
|
||||
bias_norm=_bias_norm(self._latest_bias),
|
||||
bias_norm=bias_norm(self._latest_bias),
|
||||
)
|
||||
|
||||
def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
|
||||
@@ -400,7 +384,7 @@ class Okvis2Strategy:
|
||||
|
||||
def _build_vio_output(self, raw: dict[str, Any], emitted_at_ns: int) -> VioOutput:
|
||||
try:
|
||||
pose = _se3_from_4x4(raw["pose_T_world_body"])
|
||||
pose = se3_from_4x4(raw["pose_T_world_body"])
|
||||
cov = np.asarray(raw["pose_covariance_6x6"], dtype=np.float64)
|
||||
bias = ImuBias(
|
||||
accel_bias=tuple(float(x) for x in raw["accel_bias"]), # type: ignore[arg-type]
|
||||
@@ -432,57 +416,3 @@ class Okvis2Strategy:
|
||||
emitted_at_ns=backend_ts,
|
||||
)
|
||||
|
||||
def _classify_state(self, fq: FeatureQuality) -> VioState:
|
||||
if self._reported_state == VioState.INIT and (
|
||||
self._frames_since_warmup + 1 < self._warm_start_max_frames
|
||||
):
|
||||
return VioState.INIT
|
||||
if fq.tracked < self._okvis2_cfg.degraded_feature_threshold:
|
||||
return VioState.DEGRADED
|
||||
return VioState.TRACKING
|
||||
|
||||
def _tick_lost(self, frame_id: str) -> None:
|
||||
self._consecutive_lost += 1
|
||||
if self._consecutive_lost >= self._lost_frame_threshold:
|
||||
self._reported_state = VioState.LOST
|
||||
elif self._reported_state == VioState.TRACKING:
|
||||
self._reported_state = VioState.DEGRADED
|
||||
|
||||
def _emit_transition(self, new_state: VioState, frame_id: str) -> None:
|
||||
if self._last_emitted_state == new_state:
|
||||
return
|
||||
self._last_emitted_state = new_state
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=_now_iso(),
|
||||
producer_id=_PRODUCER_ID,
|
||||
kind="vio.health",
|
||||
payload={
|
||||
"state": new_state.value,
|
||||
"consecutive_lost": self._consecutive_lost,
|
||||
"bias_norm": _bias_norm(self._latest_bias),
|
||||
"strategy_label": _STRATEGY_LABEL,
|
||||
"frame_id": frame_id,
|
||||
},
|
||||
)
|
||||
self._fdr.enqueue(record)
|
||||
|
||||
|
||||
def _frame_ts_ns(frame: NavCameraFrame) -> int:
|
||||
"""Convert ``NavCameraFrame.timestamp`` to monotonic-ns.
|
||||
|
||||
Uses the datetime's UTC epoch nanoseconds so the value is
|
||||
monotonically increasing across frames (frame source guarantees
|
||||
strictly increasing timestamps per the FrameSource contract).
|
||||
"""
|
||||
return int(frame.timestamp.timestamp() * 1e9)
|
||||
|
||||
|
||||
def _frame_image(frame: NavCameraFrame) -> np.ndarray:
|
||||
"""Coerce the frame's image into a contiguous uint8 ndarray."""
|
||||
arr = np.ascontiguousarray(frame.image, dtype=np.uint8)
|
||||
if arr.ndim < 2 or arr.ndim > 3:
|
||||
raise VioFatalError(
|
||||
f"Okvis2Strategy: NavCameraFrame.image must be 2-D or 3-D; got {arr.ndim}-D"
|
||||
)
|
||||
return arr
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""`VinsMonoStrategy` — research-only comparative C1 VIO (AZ-333).
|
||||
|
||||
Python facade over the VINS-Mono C++ loosely-coupled sliding-window VIO
|
||||
core, accessed via the pybind11 binding at
|
||||
``_native.vins_mono_binding.VinsMonoBackend`` (compiled by
|
||||
``cpp/vins_mono/CMakeLists.txt``, gated by ``BUILD_VINS_MONO=ON``).
|
||||
|
||||
Conforms to the AZ-331 :class:`VioStrategy` Protocol; consumes the
|
||||
runtime ``Config`` + an :class:`FdrClient`; constructs its other
|
||||
dependencies (logger, camera calibration) internally from ``config``
|
||||
so the strategy class matches the composition-root factory shape::
|
||||
|
||||
strategy_cls(config: Config, *, fdr_client: FdrClient)
|
||||
|
||||
This mirrors :class:`Okvis2Strategy` (AZ-332) deliberately: the AZ-331
|
||||
factory produces both via the same `(config, *, fdr_client)` shape and
|
||||
the IT-12 comparative-study harness expects the two to be drop-in
|
||||
substitutable. Behavioural differences (Ceres vs Levenberg-Marquardt,
|
||||
loosely-coupled vs tightly-coupled, marginalisation strategy) live
|
||||
under the binding boundary and are observable only via the latency /
|
||||
covariance numbers in the Step 9 comparative report — NOT via the
|
||||
Python surface.
|
||||
|
||||
Risk-2 / Risk-3 mitigation: the native binding is imported **lazily
|
||||
inside the constructor**, not at module top level. Importing this
|
||||
module with ``BUILD_VINS_MONO=OFF`` (no compiled ``.so``) is safe —
|
||||
the AZ-331 factory's build-flag gate catches that path before the
|
||||
constructor runs.
|
||||
|
||||
AC mapping (see ``_docs/02_tasks/todo/AZ-333_c1_vins_mono_strategy.md``):
|
||||
|
||||
- AC-1 : :meth:`current_strategy_label` returns ``"vins_mono"``.
|
||||
- AC-2 : :meth:`process_frame` returns :class:`VioOutput` with
|
||||
``frame_id`` echoed; covariance SPD; ``imu_bias`` non-None.
|
||||
- AC-3 : all backend / Ceres / Eigen / std::runtime_error rewrap into
|
||||
:class:`VioError` family with ``__cause__`` chain.
|
||||
- AC-4 : :meth:`reset_to_warm_start` clears state + seeds hint; second
|
||||
consecutive call does not raise.
|
||||
- AC-5 : :meth:`health_snapshot` returns INIT initially, TRACKING after
|
||||
``warm_start_max_frames`` (default 5) successful frames.
|
||||
- AC-6 : DEGRADED on feature loss; covariance Frobenius norm strictly
|
||||
increases; ``process_frame`` still returns :class:`VioOutput` (not raise).
|
||||
- AC-7 : after ``lost_frame_threshold`` (default 9) consecutive failed
|
||||
frames, raises :class:`VioFatalError`; state == LOST.
|
||||
- AC-8 : ``BUILD_VINS_MONO=OFF`` does not load this module (enforced by
|
||||
AZ-331 factory; covered in
|
||||
``tests/unit/c1_vio/test_protocol_conformance.py``).
|
||||
- AC-9 / NFR-perf : tier2 — Jetson + Derkachi-class fixture; tests
|
||||
marked ``@pytest.mark.tier2``.
|
||||
- AC-10 : exactly one ``vio.health`` FDR record per state transition;
|
||||
no spam on steady-state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.nav import (
|
||||
FeatureQuality,
|
||||
ImuBias,
|
||||
VioHealth,
|
||||
VioOutput,
|
||||
VioState,
|
||||
)
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
from gps_denied_onboard.components.c1_vio._facade_spine import (
|
||||
FacadeSpine,
|
||||
bias_norm,
|
||||
frame_image,
|
||||
frame_ts_ns,
|
||||
se3_from_4x4,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio.errors import (
|
||||
VioFatalError,
|
||||
VioInitializingError,
|
||||
)
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import (
|
||||
ImuWindow,
|
||||
NavCameraFrame,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c1_vio.config import VinsMonoConfig
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
__all__ = ["VinsMonoStrategy"]
|
||||
|
||||
|
||||
_STRATEGY_LABEL: Final[Literal["vins_mono"]] = "vins_mono"
|
||||
_PRODUCER_ID: Final[str] = "c1_vio.vins_mono"
|
||||
_LOGGER_COMPONENT: Final[str] = "c1_vio.vins_mono"
|
||||
|
||||
|
||||
class VinsMonoStrategy(FacadeSpine):
|
||||
"""Research-only :class:`VioStrategy` for IT-12 comparative study (AZ-333).
|
||||
|
||||
Constructor matches the AZ-331 composition-root factory shape::
|
||||
|
||||
VinsMonoStrategy(config: Config, *, fdr_client: FdrClient)
|
||||
|
||||
Other dependencies (calibration, logger, VINS-Mono sub-config) are
|
||||
resolved internally from ``config``. Per the C1 component
|
||||
`tests.md` C1-IT-04, the AC-2.2 MRE bound is **exempt** for this
|
||||
strategy.
|
||||
|
||||
Concurrency: single-threaded by Protocol invariant. One instance
|
||||
per camera-ingest writer thread; concurrent ``process_frame`` calls
|
||||
are undefined behaviour.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
*,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock | None = None,
|
||||
) -> None:
|
||||
c1_block = config.components["c1_vio"]
|
||||
if c1_block.strategy != _STRATEGY_LABEL:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy constructed with config.strategy="
|
||||
f"{c1_block.strategy!r}; expected {_STRATEGY_LABEL!r}. "
|
||||
"The AZ-331 factory is the only sanctioned constructor."
|
||||
)
|
||||
|
||||
self._config = config
|
||||
self._fdr = fdr_client
|
||||
self._clock: Clock = clock if clock is not None else WallClock()
|
||||
self._logger = get_logger(_LOGGER_COMPONENT)
|
||||
self._lost_frame_threshold: int = c1_block.lost_frame_threshold
|
||||
self._warm_start_max_frames: int = c1_block.warm_start_max_frames
|
||||
self._vins_cfg: VinsMonoConfig = c1_block.vins_mono
|
||||
self._feature_threshold: int = self._vins_cfg.degraded_feature_threshold
|
||||
self._producer_id: str = _PRODUCER_ID
|
||||
self._strategy_label: str = _STRATEGY_LABEL
|
||||
self._calibration: CameraCalibration | None = None
|
||||
self._frames_since_warmup: int = 0
|
||||
self._consecutive_lost: int = 0
|
||||
self._latest_bias: ImuBias = ImuBias(
|
||||
accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)
|
||||
)
|
||||
self._reported_state: VioState = VioState.INIT
|
||||
self._last_emitted_state: VioState | None = None
|
||||
|
||||
# Lazy import of the native binding — Risk-2 / Risk-3 mitigation.
|
||||
# Failure here is the BUILD_VINS_MONO=OFF path the AZ-331
|
||||
# factory's `StrategyNotAvailableError` is meant to prevent; if a
|
||||
# caller bypasses the factory and reaches this constructor with
|
||||
# the native lib absent, we surface a fatal init error.
|
||||
try:
|
||||
from gps_denied_onboard.components.c1_vio._native import (
|
||||
vins_mono_binding,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise VioFatalError(
|
||||
"VinsMonoStrategy: native binding "
|
||||
"(gps_denied_onboard.components.c1_vio._native.vins_mono_binding) "
|
||||
"is not importable. Research binary must be built with "
|
||||
"BUILD_VINS_MONO=ON; deployment binaries (airborne / "
|
||||
"operator-tooling / replay-cli) must NOT request strategy="
|
||||
"'vins_mono'."
|
||||
) from exc
|
||||
|
||||
self._binding_module = vins_mono_binding
|
||||
self._backend = self._construct_backend()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public Protocol surface.
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
imu: ImuWindow,
|
||||
calibration: CameraCalibration,
|
||||
) -> VioOutput:
|
||||
"""Hot-path call — one per nav-camera frame.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Push every IMU sample in the window into the backend; the
|
||||
strict-monotonic guard lives on the C++ side.
|
||||
2. Submit the frame.
|
||||
3. If the backend produced an output, classify health and
|
||||
build the :class:`VioOutput` DTO.
|
||||
4. If no output: tick the lost-frame counter; emit a state
|
||||
transition record if needed.
|
||||
"""
|
||||
self._calibration = calibration
|
||||
frame_id_str = str(frame.frame_id)
|
||||
emitted_at_ns = self._clock.monotonic_ns()
|
||||
|
||||
try:
|
||||
self._push_imu_window(imu)
|
||||
produced = self._backend.add_frame(
|
||||
frame_id_str,
|
||||
frame_ts_ns(frame),
|
||||
frame_image(frame, producer_id="VinsMonoStrategy"),
|
||||
)
|
||||
except self._binding_module.VinsMonoInitException as exc:
|
||||
self._emit_transition(VioState.INIT, frame_id_str)
|
||||
raise VioInitializingError(
|
||||
f"VINS-Mono backend reports INIT while processing frame "
|
||||
f"{frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
except self._binding_module.VinsMonoOptimizationException as exc:
|
||||
# Treat as a degraded frame: emit no VioOutput from this
|
||||
# path — callers expect either a VioOutput or a VioError;
|
||||
# we choose error here so C5 can fall back, matching AC-3.
|
||||
self._tick_lost(frame_id_str)
|
||||
if self._reported_state == VioState.LOST:
|
||||
self._emit_transition(VioState.LOST, frame_id_str)
|
||||
raise VioFatalError(
|
||||
f"VINS-Mono backend exhausted lost-frame budget at "
|
||||
f"{frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
self._emit_transition(self._reported_state, frame_id_str)
|
||||
raise VioInitializingError(
|
||||
f"VINS-Mono backend optimisation failure at {frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
except self._binding_module.VinsMonoFatalException as exc:
|
||||
self._emit_transition(VioState.LOST, frame_id_str)
|
||||
raise VioFatalError(
|
||||
f"VINS-Mono backend fatal exception at {frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
# Catch-all for unmapped backend exceptions. Re-classify as
|
||||
# fatal — we explicitly forbid raw library exceptions across
|
||||
# the public boundary.
|
||||
raise VioFatalError(
|
||||
f"VINS-Mono backend raised an unmapped exception at "
|
||||
f"{frame_id_str!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
if not produced:
|
||||
# Frame consumed but no estimator update yet — INIT path
|
||||
# while VINS-Mono's SfM bootstrap warms up.
|
||||
self._emit_transition(VioState.INIT, frame_id_str)
|
||||
raise VioInitializingError(
|
||||
f"VinsMonoStrategy: backend has not yet emitted an "
|
||||
f"estimator update at {frame_id_str!r}"
|
||||
)
|
||||
|
||||
raw = self._backend.get_latest_output()
|
||||
if raw is None:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: backend reported a new output for "
|
||||
f"{frame_id_str!r} but get_latest_output() returned None"
|
||||
)
|
||||
|
||||
vio_output = self._build_vio_output(raw, emitted_at_ns)
|
||||
self._consecutive_lost = 0
|
||||
new_state = self._classify_state(vio_output.feature_quality)
|
||||
if new_state != self._reported_state:
|
||||
self._reported_state = new_state
|
||||
self._emit_transition(new_state, frame_id_str)
|
||||
|
||||
if new_state in (VioState.INIT, VioState.TRACKING):
|
||||
self._frames_since_warmup += 1
|
||||
|
||||
if self._vins_cfg.per_frame_debug_log:
|
||||
self._logger.debug(
|
||||
"vins_mono.process_frame",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "vio.tick",
|
||||
"frame_id": frame_id_str,
|
||||
"kv": {
|
||||
"state": self._reported_state.value,
|
||||
"tracked": vio_output.feature_quality.tracked,
|
||||
"mre_px": vio_output.feature_quality.mre_px,
|
||||
"emitted_at_ns": vio_output.emitted_at_ns,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return vio_output
|
||||
|
||||
def reset_to_warm_start(self, hint: WarmStartPose) -> None:
|
||||
"""Destructive re-init from an F8-reboot warm-start hint.
|
||||
|
||||
Idempotent across consecutive calls (AC-4) — a second call
|
||||
without an intervening ``process_frame`` reseats the backend
|
||||
again without raising.
|
||||
"""
|
||||
try:
|
||||
body_T_world = np.asarray(hint.body_T_world.matrix(), dtype=np.float64)
|
||||
except AttributeError as exc:
|
||||
raise VioFatalError(
|
||||
"VinsMonoStrategy.reset_to_warm_start: hint.body_T_world is "
|
||||
"not a gtsam.Pose3 (missing .matrix())"
|
||||
) from exc
|
||||
|
||||
velocity = np.asarray(hint.velocity_b, dtype=np.float64)
|
||||
accel_bias = np.asarray(hint.bias.accel_bias, dtype=np.float64)
|
||||
gyro_bias = np.asarray(hint.bias.gyro_bias, dtype=np.float64)
|
||||
|
||||
try:
|
||||
self._backend.reset(body_T_world, velocity, accel_bias, gyro_bias)
|
||||
except self._binding_module.VinsMonoInitException as exc:
|
||||
raise VioFatalError(
|
||||
f"VINS-Mono backend rejected warm-start reset: {exc}"
|
||||
) from exc
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise VioFatalError(
|
||||
f"VINS-Mono backend raised an unmapped exception during reset: {exc}"
|
||||
) from exc
|
||||
|
||||
self._latest_bias = hint.bias
|
||||
self._frames_since_warmup = 0
|
||||
self._consecutive_lost = 0
|
||||
self._reported_state = VioState.INIT
|
||||
self._emit_transition(VioState.INIT, frame_id="")
|
||||
|
||||
def health_snapshot(self) -> VioHealth:
|
||||
"""Most-recent health state — no backend call (cheap)."""
|
||||
return VioHealth(
|
||||
state=self._reported_state,
|
||||
consecutive_lost=self._consecutive_lost,
|
||||
bias_norm=bias_norm(self._latest_bias),
|
||||
)
|
||||
|
||||
def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
|
||||
return _STRATEGY_LABEL
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers.
|
||||
|
||||
def _construct_backend(self) -> Any:
|
||||
"""Build the backend from config — calibration path is optional
|
||||
because the unit-test fake-binding path skips real intrinsics.
|
||||
|
||||
Tests inject a fake module at ``sys.modules`` before construction
|
||||
(see ``tests/unit/c1_vio/conftest.py``); the fake's
|
||||
``VinsMonoBackend`` accepts whatever this method passes.
|
||||
"""
|
||||
K = self._load_camera_intrinsics()
|
||||
yaml_config = self._render_yaml_config()
|
||||
try:
|
||||
return self._binding_module.VinsMonoBackend(yaml_config, K)
|
||||
except self._binding_module.VinsMonoInitException as exc:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: backend init failed: {exc}"
|
||||
) from exc
|
||||
|
||||
def _load_camera_intrinsics(self) -> np.ndarray:
|
||||
"""Load 3x3 camera intrinsics from the calibration path.
|
||||
|
||||
Returns the identity matrix when the runtime block has no
|
||||
path configured — the unit-test path overrides this via the
|
||||
fake binding's ctor anyway, and a research binary refusing
|
||||
to start on a missing calibration is preferable to silently
|
||||
emitting wrong poses (handled by the YAML loader downstream).
|
||||
"""
|
||||
path = self._config.runtime.camera_calibration_path
|
||||
if not path:
|
||||
return np.eye(3, dtype=np.float64)
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: failed to load camera calibration from "
|
||||
f"{path!r}: {exc}"
|
||||
) from exc
|
||||
K_raw = blob.get("intrinsics_3x3")
|
||||
if K_raw is None:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: calibration file {path!r} is missing the "
|
||||
"'intrinsics_3x3' field"
|
||||
)
|
||||
K = np.asarray(K_raw, dtype=np.float64)
|
||||
if K.shape != (3, 3):
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: intrinsics_3x3 must be 3x3; got shape {K.shape}"
|
||||
)
|
||||
return K
|
||||
|
||||
def _render_yaml_config(self) -> str:
|
||||
"""Render the VinsMonoConfig sub-block into a VINS-Mono YAML snippet.
|
||||
|
||||
VINS-Mono reads a YAML config string at construction. Only the
|
||||
knobs AZ-333 exposes are rendered; VINS-Mono-internal defaults
|
||||
cover the rest.
|
||||
"""
|
||||
cfg = self._vins_cfg
|
||||
return (
|
||||
"# AZ-333 — generated VINS-Mono config (see VinsMonoConfig in c1_vio/config.py)\n"
|
||||
f"sliding_window_size: {cfg.sliding_window_size}\n"
|
||||
f"feature_min_tracked: {cfg.feature_min_tracked}\n"
|
||||
f"feature_min_parallax_px: {cfg.feature_min_parallax_px}\n"
|
||||
f"marginalisation_strategy: {cfg.marginalisation_strategy}\n"
|
||||
f"max_optimization_iters: {cfg.max_optimization_iters}\n"
|
||||
)
|
||||
|
||||
def _push_imu_window(self, imu: ImuWindow) -> None:
|
||||
for sample in imu.samples:
|
||||
self._backend.add_imu(
|
||||
sample.ts_ns,
|
||||
np.asarray(sample.accel_xyz, dtype=np.float64),
|
||||
np.asarray(sample.gyro_xyz, dtype=np.float64),
|
||||
)
|
||||
|
||||
def _build_vio_output(self, raw: dict[str, Any], emitted_at_ns: int) -> VioOutput:
|
||||
try:
|
||||
pose = se3_from_4x4(raw["pose_T_world_body"])
|
||||
cov = np.asarray(raw["pose_covariance_6x6"], dtype=np.float64)
|
||||
bias = ImuBias(
|
||||
accel_bias=tuple(float(x) for x in raw["accel_bias"]), # type: ignore[arg-type]
|
||||
gyro_bias=tuple(float(x) for x in raw["gyro_bias"]), # type: ignore[arg-type]
|
||||
)
|
||||
feature_quality = FeatureQuality(
|
||||
tracked=int(raw["tracked_features"]),
|
||||
new=int(raw["new_features"]),
|
||||
lost=int(raw["lost_features"]),
|
||||
mean_parallax=float(raw["mean_parallax"]),
|
||||
mre_px=float(raw["mre_px"]),
|
||||
)
|
||||
backend_ts = int(raw.get("emitted_at_ns") or emitted_at_ns)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: backend output is malformed: {exc}"
|
||||
) from exc
|
||||
|
||||
if cov.shape != (6, 6):
|
||||
raise VioFatalError(
|
||||
f"VinsMonoStrategy: pose_covariance_6x6 has shape {cov.shape}; "
|
||||
"expected (6, 6)"
|
||||
)
|
||||
|
||||
self._latest_bias = bias
|
||||
return VioOutput(
|
||||
frame_id=raw["frame_id"],
|
||||
relative_pose_T=pose,
|
||||
pose_covariance_6x6=cov,
|
||||
imu_bias=bias,
|
||||
feature_quality=feature_quality,
|
||||
emitted_at_ns=backend_ts,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Warm-start hint persistence (AZ-335 / E-C1).
|
||||
|
||||
C1-internal storage layer for the warm-start + F8 reboot recovery
|
||||
wiring. Defines:
|
||||
|
||||
- :class:`WarmStartHintStore` (PEP 544 Protocol) — the typed store
|
||||
contract. Default impl is :class:`JsonSidecarWarmStartHintStore`;
|
||||
a future operator-managed store (e.g. Redis-backed) can plug in via
|
||||
the same Protocol without touching the wiring.
|
||||
- :class:`LoadedWarmStartHint` (frozen dataclass) — what
|
||||
:meth:`WarmStartHintStore.load` returns: the pose hint plus the
|
||||
AC-5.3 baseline covariance norm captured at the same save.
|
||||
- :class:`JsonSidecarWarmStartHintStore` — atomic-JSON-write +
|
||||
SHA-256 sidecar persistence via :class:`Sha256Sidecar` (AZ-280).
|
||||
- :class:`WarmStartFcSource` (PEP 544 Protocol) — the consumer-side
|
||||
structural cut over the C8 ``FcAdapter`` family that
|
||||
:func:`prime_warm_start_from_fc` consumes. Defined here (NOT
|
||||
imported from c8) per AZ-507's cross-component rule: a c1 module
|
||||
must not import from another component's module; consumer-side
|
||||
Protocol cuts live with the consumer.
|
||||
|
||||
The on-disk schema (JSON) is owned by this module; ``version`` is
|
||||
always ``1`` for this cycle. The schema layout is documented inline
|
||||
in :func:`_serialise_envelope` / :func:`_deserialise_envelope` so
|
||||
the round-trip contract stays close to the wire format.
|
||||
|
||||
The store is L2 component-internal (NOT in
|
||||
``c1_vio/__init__.py``'s public surface); the runtime root pulls
|
||||
the concrete class via this module path at composition time, the
|
||||
same lazy-import pattern used by the AZ-331 vio_factory for
|
||||
strategy modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.nav import ImuBias, WarmStartPose
|
||||
from gps_denied_onboard.helpers.se3_utils import (
|
||||
Se3InvalidMatrixError,
|
||||
matrix_to_se3,
|
||||
se3_to_matrix,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
SIDECAR_SUFFIX,
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = [
|
||||
"HINT_FILENAME",
|
||||
"HINT_SCHEMA_VERSION",
|
||||
"JsonSidecarWarmStartHintStore",
|
||||
"LoadedWarmStartHint",
|
||||
"WarmStartFcSource",
|
||||
"WarmStartHintStore",
|
||||
]
|
||||
|
||||
|
||||
HINT_FILENAME: str = "c1_warm_start.json"
|
||||
HINT_SCHEMA_VERSION: int = 1
|
||||
_LOGGER_NAME: str = "components.c1_vio.warm_start_store"
|
||||
_LOGGER_COMPONENT: str = "c1_vio"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedWarmStartHint:
|
||||
"""What :meth:`WarmStartHintStore.load` returns on success.
|
||||
|
||||
``pose`` is the persisted :class:`WarmStartPose` deep-equal to the
|
||||
last saved hint. ``pre_reboot_covariance_norm`` is the Frobenius
|
||||
norm of the strategy's last steady-state ``pose_covariance_6x6``
|
||||
captured by the wiring at save time — the F8 reload path uses
|
||||
this as the AC-5.3 / AC-8 "no fake confidence" floor.
|
||||
``calibration_id`` is the camera-calibration identifier the hint
|
||||
was produced under; the wiring rejects the hint if the current
|
||||
calibration differs (Risk 2 mitigation).
|
||||
"""
|
||||
|
||||
pose: WarmStartPose
|
||||
pre_reboot_covariance_norm: float
|
||||
calibration_id: str
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WarmStartHintStore(Protocol):
|
||||
"""Persistence contract for a single warm-start hint per c1_vio process.
|
||||
|
||||
Implementations MUST satisfy:
|
||||
|
||||
- :meth:`save` is atomic (no half-written file is ever loadable).
|
||||
- :meth:`load` returns ``None`` on cold start (no prior hint),
|
||||
on sidecar mismatch (corruption), and on calibration mismatch
|
||||
(Risk 2). All three cases are observable via INFO/WARN logs.
|
||||
- :meth:`clear` removes both the payload file and its sidecar
|
||||
together (no half-cleared state).
|
||||
"""
|
||||
|
||||
def save(
|
||||
self,
|
||||
hint: WarmStartPose,
|
||||
*,
|
||||
pre_reboot_covariance_norm: float,
|
||||
) -> None: ...
|
||||
|
||||
def load(self) -> LoadedWarmStartHint | None: ...
|
||||
|
||||
def clear(self) -> None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WarmStartFcSource(Protocol):
|
||||
"""Consumer-side cut over the C8 ``FcAdapter`` family (AZ-507).
|
||||
|
||||
The F2 takeoff prime path calls :meth:`fetch_warm_start_pose` to
|
||||
pull the FC EKF's last valid GPS + IMU-extrapolated pose. The
|
||||
return is ``None`` when the FC has no valid GPS yet (the prime
|
||||
path then degrades to cold-start with a WARN log; AC-NFR-no-crash).
|
||||
|
||||
The runtime-root composition wires a thin adapter from the
|
||||
concrete C8 :class:`FcAdapter` to this Protocol; tests inject a
|
||||
fake matching this surface directly. NEVER import a c8 concrete
|
||||
adapter from inside c1_vio.
|
||||
"""
|
||||
|
||||
def fetch_warm_start_pose(self) -> WarmStartPose | None: ...
|
||||
|
||||
def calibration_id(self) -> str: ...
|
||||
|
||||
|
||||
def _serialise_envelope(
|
||||
hint: WarmStartPose,
|
||||
*,
|
||||
pre_reboot_covariance_norm: float,
|
||||
calibration_id: str,
|
||||
) -> bytes:
|
||||
"""Pack ``hint`` into the on-disk JSON envelope.
|
||||
|
||||
Schema v1 layout (top-level dict):
|
||||
|
||||
- ``version`` (int) — always :data:`HINT_SCHEMA_VERSION`.
|
||||
- ``calibration_id`` (str) — see Risk 2 mitigation.
|
||||
- ``pre_reboot_covariance_norm`` (float) — AC-5.3 / AC-8 baseline.
|
||||
- ``pose`` (dict) — the :class:`WarmStartPose` flattened to
|
||||
JSON-native types: ``body_T_world_4x4`` (4-list of 4-list of
|
||||
float), ``velocity_b`` (3-list of float), ``bias`` (dict with
|
||||
``accel_bias`` + ``gyro_bias`` 3-lists of float),
|
||||
``captured_at_ns`` (int).
|
||||
"""
|
||||
matrix = se3_to_matrix(hint.body_T_world)
|
||||
envelope: dict[str, Any] = {
|
||||
"version": HINT_SCHEMA_VERSION,
|
||||
"calibration_id": calibration_id,
|
||||
"pre_reboot_covariance_norm": float(pre_reboot_covariance_norm),
|
||||
"pose": {
|
||||
"body_T_world_4x4": matrix.tolist(),
|
||||
"velocity_b": [float(v) for v in hint.velocity_b],
|
||||
"bias": {
|
||||
"accel_bias": [float(v) for v in hint.bias.accel_bias],
|
||||
"gyro_bias": [float(v) for v in hint.bias.gyro_bias],
|
||||
},
|
||||
"captured_at_ns": int(hint.captured_at_ns),
|
||||
},
|
||||
}
|
||||
return json.dumps(envelope, sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
def _deserialise_envelope(
|
||||
payload: bytes,
|
||||
) -> tuple[WarmStartPose, float, str]:
|
||||
"""Inverse of :func:`_serialise_envelope`.
|
||||
|
||||
Raises :class:`ValueError` (with context) on any structural
|
||||
deviation from schema v1 — the calling :meth:`load` routes those
|
||||
failures through the same WARN-and-return-None path as a sidecar
|
||||
mismatch (the file is not loadable; cold-start is the right
|
||||
fallback).
|
||||
"""
|
||||
try:
|
||||
decoded = json.loads(payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"warm-start hint payload is not valid UTF-8 JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError(
|
||||
f"warm-start hint payload must decode to a dict; got {type(decoded).__name__}"
|
||||
)
|
||||
version = decoded.get("version")
|
||||
if version != HINT_SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"warm-start hint version mismatch: expected {HINT_SCHEMA_VERSION}, got {version!r}"
|
||||
)
|
||||
calibration_id = decoded.get("calibration_id")
|
||||
if not isinstance(calibration_id, str) or not calibration_id:
|
||||
raise ValueError(
|
||||
f"warm-start hint envelope missing non-empty calibration_id; got {calibration_id!r}"
|
||||
)
|
||||
pre_reboot_covariance_norm = decoded.get("pre_reboot_covariance_norm")
|
||||
if not isinstance(pre_reboot_covariance_norm, (int, float)) or isinstance(
|
||||
pre_reboot_covariance_norm, bool
|
||||
):
|
||||
raise ValueError(
|
||||
"warm-start hint envelope.pre_reboot_covariance_norm must be a float; "
|
||||
f"got {pre_reboot_covariance_norm!r}"
|
||||
)
|
||||
pose_dict = decoded.get("pose")
|
||||
if not isinstance(pose_dict, dict):
|
||||
raise ValueError(
|
||||
f"warm-start hint envelope.pose must be a dict; got {type(pose_dict).__name__}"
|
||||
)
|
||||
matrix_list = pose_dict.get("body_T_world_4x4")
|
||||
if not isinstance(matrix_list, list) or len(matrix_list) != 4:
|
||||
raise ValueError("warm-start hint pose.body_T_world_4x4 must be a 4-list of rows")
|
||||
try:
|
||||
matrix = np.asarray(matrix_list, dtype=np.float64)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"warm-start hint pose.body_T_world_4x4 not numeric: {exc}") from exc
|
||||
try:
|
||||
body_T_world = matrix_to_se3(matrix)
|
||||
except Se3InvalidMatrixError as exc:
|
||||
raise ValueError(f"warm-start hint pose.body_T_world_4x4 not a valid SE(3): {exc}") from exc
|
||||
|
||||
velocity_list = pose_dict.get("velocity_b")
|
||||
if not isinstance(velocity_list, list) or len(velocity_list) != 3:
|
||||
raise ValueError("warm-start hint pose.velocity_b must be a 3-list of floats")
|
||||
velocity_b = (
|
||||
float(velocity_list[0]),
|
||||
float(velocity_list[1]),
|
||||
float(velocity_list[2]),
|
||||
)
|
||||
|
||||
bias_dict = pose_dict.get("bias")
|
||||
if not isinstance(bias_dict, dict):
|
||||
raise ValueError("warm-start hint pose.bias must be a dict")
|
||||
accel_list = bias_dict.get("accel_bias")
|
||||
gyro_list = bias_dict.get("gyro_bias")
|
||||
if (
|
||||
not isinstance(accel_list, list)
|
||||
or len(accel_list) != 3
|
||||
or not isinstance(gyro_list, list)
|
||||
or len(gyro_list) != 3
|
||||
):
|
||||
raise ValueError(
|
||||
"warm-start hint pose.bias must contain 3-list accel_bias and 3-list gyro_bias"
|
||||
)
|
||||
bias = ImuBias(
|
||||
accel_bias=(float(accel_list[0]), float(accel_list[1]), float(accel_list[2])),
|
||||
gyro_bias=(float(gyro_list[0]), float(gyro_list[1]), float(gyro_list[2])),
|
||||
)
|
||||
|
||||
captured_at_ns = pose_dict.get("captured_at_ns")
|
||||
if not isinstance(captured_at_ns, int) or isinstance(captured_at_ns, bool):
|
||||
raise ValueError(
|
||||
f"warm-start hint pose.captured_at_ns must be an int; got {captured_at_ns!r}"
|
||||
)
|
||||
|
||||
pose = WarmStartPose(
|
||||
body_T_world=body_T_world,
|
||||
velocity_b=velocity_b,
|
||||
bias=bias,
|
||||
captured_at_ns=captured_at_ns,
|
||||
)
|
||||
return pose, float(pre_reboot_covariance_norm), calibration_id
|
||||
|
||||
|
||||
class JsonSidecarWarmStartHintStore:
|
||||
"""Default :class:`WarmStartHintStore` impl backed by JSON + SHA-256 sidecar.
|
||||
|
||||
``store_dir`` is the directory the hint file lives in; created on
|
||||
first ``save`` if missing. ``calibration_id`` is bound at
|
||||
construction time — the composition root reads
|
||||
:class:`CameraCalibration.id` once and passes it here. A loaded
|
||||
hint whose ``calibration_id`` differs from the constructor value
|
||||
is rejected (returns ``None`` + WARN log) per Risk 2.
|
||||
|
||||
The atomic-write and sidecar-verify guarantees come from
|
||||
:class:`Sha256Sidecar` (AZ-280); this class never opens the
|
||||
payload file directly except through that helper. The class is
|
||||
process-local (no cross-process locking) — by AZ-331 invariant
|
||||
the c1_vio strategy is single-instanced per process and the
|
||||
composition root owns this store.
|
||||
"""
|
||||
|
||||
def __init__(self, store_dir: Path, *, calibration_id: str) -> None:
|
||||
if not calibration_id:
|
||||
raise ValueError(
|
||||
"JsonSidecarWarmStartHintStore.calibration_id must be a non-empty string"
|
||||
)
|
||||
self._store_dir = Path(store_dir)
|
||||
self._calibration_id = calibration_id
|
||||
self._payload_path = self._store_dir / HINT_FILENAME
|
||||
self._sidecar_path = Path(str(self._payload_path) + SIDECAR_SUFFIX)
|
||||
self._log = get_logger(_LOGGER_NAME)
|
||||
|
||||
@property
|
||||
def payload_path(self) -> Path:
|
||||
"""The on-disk JSON file path (exposed for tests + forensics)."""
|
||||
return self._payload_path
|
||||
|
||||
@property
|
||||
def sidecar_path(self) -> Path:
|
||||
"""The sidecar ``<payload>.sha256`` path (exposed for tests + forensics)."""
|
||||
return self._sidecar_path
|
||||
|
||||
def save(
|
||||
self,
|
||||
hint: WarmStartPose,
|
||||
*,
|
||||
pre_reboot_covariance_norm: float,
|
||||
) -> None:
|
||||
"""Write the envelope atomically + sidecar.
|
||||
|
||||
Failures (write errors, parent-dir creation errors) propagate
|
||||
as :class:`Sha256SidecarError` / :class:`OSError` so the
|
||||
caller can route them through the wiring's no-crash policy
|
||||
(the wiring catches these and emits an ERROR log per
|
||||
AC-NFR-no-crash; the process keeps running and falls through
|
||||
to cold-start on the next prime).
|
||||
"""
|
||||
self._store_dir.mkdir(parents=True, exist_ok=True)
|
||||
payload = _serialise_envelope(
|
||||
hint,
|
||||
pre_reboot_covariance_norm=pre_reboot_covariance_norm,
|
||||
calibration_id=self._calibration_id,
|
||||
)
|
||||
Sha256Sidecar.write_atomic_and_sidecar(self._payload_path, payload)
|
||||
|
||||
def load(self) -> LoadedWarmStartHint | None:
|
||||
"""Return the persisted hint, or ``None`` on any non-loadable state.
|
||||
|
||||
Branches that emit ``None``:
|
||||
|
||||
- Payload file does not exist (cold start; no INFO log here —
|
||||
the prime path emits ``c1.warm_start.cold_start``).
|
||||
- Sidecar does not exist or is malformed (corruption — WARN
|
||||
log ``c1.warm_start.corrupted`` with the offending path).
|
||||
The file is NOT silently deleted (operator may want to
|
||||
forensically inspect — AC-2).
|
||||
- SHA-256 mismatch (corruption — same WARN log).
|
||||
- JSON envelope structurally invalid (corruption — same WARN
|
||||
log; the on-disk file is left intact).
|
||||
- ``calibration_id`` mismatch (Risk 2 — WARN log
|
||||
``c1.warm_start.calibration_mismatch``; not the same kind
|
||||
as ``corrupted`` because the file IS valid, just stale).
|
||||
"""
|
||||
if not self._payload_path.exists():
|
||||
return None
|
||||
try:
|
||||
verified = Sha256Sidecar.verify(self._payload_path)
|
||||
except Sha256SidecarError as exc:
|
||||
self._emit_corrupted_warning(reason=str(exc))
|
||||
return None
|
||||
if not verified:
|
||||
self._emit_corrupted_warning(reason="sha256_mismatch")
|
||||
return None
|
||||
try:
|
||||
payload = self._payload_path.read_bytes()
|
||||
except OSError as exc:
|
||||
self._emit_corrupted_warning(reason=f"oserror: {exc}")
|
||||
return None
|
||||
try:
|
||||
pose, pre_reboot_norm, on_disk_calibration_id = _deserialise_envelope(payload)
|
||||
except ValueError as exc:
|
||||
self._emit_corrupted_warning(reason=str(exc))
|
||||
return None
|
||||
if on_disk_calibration_id != self._calibration_id:
|
||||
self._log.warning(
|
||||
"warm-start hint calibration mismatch",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.calibration_mismatch",
|
||||
"kv": {
|
||||
"path": str(self._payload_path),
|
||||
"saved_calibration_id": on_disk_calibration_id,
|
||||
"current_calibration_id": self._calibration_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
return None
|
||||
return LoadedWarmStartHint(
|
||||
pose=pose,
|
||||
pre_reboot_covariance_norm=pre_reboot_norm,
|
||||
calibration_id=on_disk_calibration_id,
|
||||
)
|
||||
|
||||
def _emit_corrupted_warning(self, *, reason: str) -> None:
|
||||
"""Single emission point for the AC-2 ``c1.warm_start.corrupted`` WARN."""
|
||||
self._log.warning(
|
||||
"warm-start hint corrupted",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.corrupted",
|
||||
"kv": {
|
||||
"path": str(self._payload_path),
|
||||
"reason": reason,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove both the payload file and its sidecar.
|
||||
|
||||
Idempotent — missing files are not an error. Emits ONE INFO
|
||||
log on every invocation, regardless of whether a file existed,
|
||||
so the operator log shows the explicit clear action.
|
||||
"""
|
||||
for path in (self._payload_path, self._sidecar_path):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
self._log.error(
|
||||
"warm-start hint clear failed",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.clear_failed",
|
||||
"kv": {
|
||||
"path": str(path),
|
||||
"reason": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise
|
||||
self._log.info(
|
||||
"warm-start hint store cleared",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.cleared",
|
||||
"kv": {
|
||||
"store_dir": str(self._store_dir),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -47,6 +47,28 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
||||
"vio.health": frozenset(
|
||||
{"state", "consecutive_lost", "bias_norm", "strategy_label", "frame_id"}
|
||||
),
|
||||
# AZ-335 / E-C1: emitted by the warm-start wiring on every successful
|
||||
# `prime_warm_start_*` invocation (F2 takeoff load, F8 reboot reload,
|
||||
# cold-start fall-through). Exactly ONE record per prime call.
|
||||
# `source` is one of "f2_takeoff_fc" | "f8_reboot_disk" |
|
||||
# "cold_start_no_hint" — distinguishes the three runtime paths so
|
||||
# post-flight forensics can answer "did this flight reuse a prior
|
||||
# hint?". `bias_norm` is the L2 norm of the loaded hint's accel||gyro
|
||||
# bias (None on cold start, since there is no hint). `staleness_ns`
|
||||
# is the monotonic-ns delta between hint capture and prime time
|
||||
# (None on cold start). `pre_reboot_covariance_norm` is the AC-8
|
||||
# baseline carried alongside the hint on the F8 path (None on F2
|
||||
# and cold start, since the wiring's covariance floor is only
|
||||
# enforced on the F8 reload path).
|
||||
"vio.warm_start": frozenset(
|
||||
{
|
||||
"source",
|
||||
"strategy_label",
|
||||
"bias_norm",
|
||||
"staleness_ns",
|
||||
"pre_reboot_covariance_norm",
|
||||
}
|
||||
),
|
||||
"state.tick": frozenset({"frame_id", "fused_pose", "covariance_2x2", "estimator_label"}),
|
||||
"tile_match": frozenset({"frame_id", "tile_id", "score", "match_count", "ransac_inliers"}),
|
||||
"overrun": frozenset({"producer_id", "dropped_count"}),
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"""C1 warm-start runtime wiring (AZ-335 / E-C1).
|
||||
|
||||
Cross-strategy orchestration for warm-start hint persistence + F2
|
||||
takeoff load + F8 reboot recovery. The wiring lives at the
|
||||
composition root because the concerns it implements span more than
|
||||
the :class:`VioStrategy` Protocol surface:
|
||||
|
||||
- AC-5.1 / AC-5.3 require a hint flow ``FC EKF → strategy``
|
||||
(F2 takeoff) and ``disk → strategy`` (F8 reboot) that no single
|
||||
strategy can implement on its own.
|
||||
- The post-reset covariance inflation + AC-5.3 "no fake confidence"
|
||||
floor is enforced HERE, not inside any strategy — adding the
|
||||
inflation to a strategy would double-inflate when the wiring also
|
||||
inflates (Constraints, AZ-335 task spec).
|
||||
- The per-frame save throttle keeps disk I/O bounded at the 3 Hz
|
||||
steady-state frame rate.
|
||||
|
||||
Public surface:
|
||||
|
||||
- :class:`WarmStartWiredStrategy` — a :class:`VioStrategy` impl that
|
||||
wraps any concrete :class:`VioStrategy` (OKVIS2 / VINS-Mono /
|
||||
KLT-RANSAC) with the per-frame save + post-reset covariance
|
||||
inflation + AC-8 baseline floor. Exposes the standard Protocol
|
||||
methods PLUS :meth:`prime_post_reboot` which the F8 prime path
|
||||
uses to install the loaded baseline.
|
||||
- :func:`prime_warm_start_from_disk` — F8 reboot prime hook.
|
||||
- :func:`prime_warm_start_from_fc` — F2 takeoff prime hook.
|
||||
|
||||
The composition root constructs a :class:`WarmStartWiredStrategy`
|
||||
from ``runtime_root.vio_factory.build_vio_strategy(config,
|
||||
fdr_client=...)`` and the per-binary :class:`WarmStartHintStore`,
|
||||
then calls :func:`prime_warm_start_from_disk` once at process
|
||||
startup before the first ``process_frame``. The F2 hook is invoked
|
||||
on the FC's ``flight_state`` transition to ``IN_AIR`` (operator-side
|
||||
or auto-detected; that wiring is owned by the composition root, not
|
||||
this module).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.nav import (
|
||||
ImuWindow,
|
||||
NavCameraFrame,
|
||||
VioHealth,
|
||||
VioOutput,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio._facade_spine import bias_norm, now_iso
|
||||
from gps_denied_onboard.components.c1_vio.interface import VioStrategy
|
||||
from gps_denied_onboard.components.c1_vio.warm_start_store import (
|
||||
LoadedWarmStartHint,
|
||||
WarmStartFcSource,
|
||||
WarmStartHintStore,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.records import CURRENT_SCHEMA_VERSION, FdrRecord
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WARM_START_PRODUCER_ID",
|
||||
"WarmStartWiredStrategy",
|
||||
"prime_warm_start_from_disk",
|
||||
"prime_warm_start_from_fc",
|
||||
]
|
||||
|
||||
|
||||
WARM_START_PRODUCER_ID: str = "components.c1_vio.warm_start"
|
||||
_LOGGER_NAME: str = "components.c1_vio.warm_start_wiring"
|
||||
_LOGGER_COMPONENT: str = "c1_vio"
|
||||
_SOURCE_F2_TAKEOFF: str = "f2_takeoff_fc"
|
||||
_SOURCE_F8_REBOOT: str = "f8_reboot_disk"
|
||||
_SOURCE_COLD_START: str = "cold_start_no_hint"
|
||||
|
||||
|
||||
def _frobenius_norm(matrix: Any) -> float:
|
||||
"""Frobenius norm of a 6×6 covariance, hardened against non-array inputs."""
|
||||
arr = np.asarray(matrix, dtype=np.float64)
|
||||
return float(np.linalg.norm(arr, ord="fro"))
|
||||
|
||||
|
||||
class WarmStartWiredStrategy:
|
||||
"""Facade around a concrete :class:`VioStrategy` with AZ-335 wiring.
|
||||
|
||||
Wraps an inner strategy so that:
|
||||
|
||||
1. Every successful :meth:`process_frame` is replicated to the
|
||||
:class:`WarmStartHintStore` once every
|
||||
``warm_start_save_period_frames`` frames (AC-6).
|
||||
2. For the first ``warm_start_max_frames`` frames after every
|
||||
:meth:`reset_to_warm_start` call, the emitted
|
||||
``pose_covariance_6x6`` is multiplied by
|
||||
``post_reset_covariance_inflation_factor`` (AC-7).
|
||||
3. When a baseline floor was installed by
|
||||
:meth:`prime_post_reboot`, post-reset frames are additionally
|
||||
scaled up so their Frobenius norm is at least the saved
|
||||
pre-reboot value (AC-8 — the "no fake confidence" invariant).
|
||||
|
||||
The wrapper is itself a :class:`VioStrategy` (PEP 544 structural
|
||||
typing). ``runtime_checkable`` conformance is verified by the
|
||||
AZ-335 unit tests; downstream consumers (C5 fusion, C13 FDR)
|
||||
cannot tell the difference between the wrapped and the bare
|
||||
strategy because the public Protocol shape is preserved.
|
||||
|
||||
Per-frame save errors do NOT crash the process — a
|
||||
:class:`Sha256SidecarError` or :class:`OSError` raised by
|
||||
:meth:`WarmStartHintStore.save` is logged at ERROR (kind
|
||||
``c1.warm_start.save_failed``) and swallowed so the camera
|
||||
ingest hot path keeps flowing (AC-NFR-no-crash).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inner: VioStrategy,
|
||||
*,
|
||||
store: WarmStartHintStore,
|
||||
warm_start_max_frames: int,
|
||||
post_reset_covariance_inflation_factor: float,
|
||||
warm_start_save_period_frames: int,
|
||||
) -> None:
|
||||
if warm_start_max_frames < 1:
|
||||
raise ValueError(
|
||||
"warm_start_max_frames must be >= 1; "
|
||||
f"got {warm_start_max_frames}"
|
||||
)
|
||||
if post_reset_covariance_inflation_factor <= 1.0:
|
||||
raise ValueError(
|
||||
"post_reset_covariance_inflation_factor must be > 1.0 "
|
||||
"(1.0 would defeat AC-5.3 / AC-8 floor); "
|
||||
f"got {post_reset_covariance_inflation_factor}"
|
||||
)
|
||||
if warm_start_save_period_frames < 1:
|
||||
raise ValueError(
|
||||
"warm_start_save_period_frames must be >= 1; "
|
||||
f"got {warm_start_save_period_frames}"
|
||||
)
|
||||
self._inner = inner
|
||||
self._store = store
|
||||
self._max_frames = warm_start_max_frames
|
||||
self._inflation_factor = float(post_reset_covariance_inflation_factor)
|
||||
self._save_period = warm_start_save_period_frames
|
||||
self._post_reset_remaining: int = 0
|
||||
self._baseline_floor: float = 0.0
|
||||
self._frames_since_save: int = 0
|
||||
self._last_emitted_covariance_norm: float = 0.0
|
||||
self._log = get_logger(_LOGGER_NAME)
|
||||
|
||||
@property
|
||||
def post_reset_remaining(self) -> int:
|
||||
"""Frames left in the active inflation window (0 in steady-state)."""
|
||||
return self._post_reset_remaining
|
||||
|
||||
@property
|
||||
def baseline_floor(self) -> float:
|
||||
"""Currently installed AC-8 covariance floor (0.0 when no F8 prime)."""
|
||||
return self._baseline_floor
|
||||
|
||||
@property
|
||||
def last_emitted_covariance_norm(self) -> float:
|
||||
"""Frobenius norm of the last :class:`VioOutput` returned to the consumer."""
|
||||
return self._last_emitted_covariance_norm
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
imu: ImuWindow,
|
||||
calibration: "CameraCalibration",
|
||||
) -> VioOutput:
|
||||
"""Forward to inner strategy, then apply inflation + throttled save."""
|
||||
out = self._inner.process_frame(frame, imu, calibration)
|
||||
if self._post_reset_remaining > 0:
|
||||
out = self._apply_post_reset_inflation(out)
|
||||
self._post_reset_remaining -= 1
|
||||
self._last_emitted_covariance_norm = _frobenius_norm(out.pose_covariance_6x6)
|
||||
self._frames_since_save += 1
|
||||
if self._frames_since_save >= self._save_period:
|
||||
self._frames_since_save = 0
|
||||
self._save_hint_from_output(out)
|
||||
return out
|
||||
|
||||
def reset_to_warm_start(self, hint: WarmStartPose) -> None:
|
||||
"""Protocol method: forward to inner, arm inflation window WITHOUT a floor.
|
||||
|
||||
Used by the F2 takeoff prime path — the FC EKF supplies a
|
||||
fresh pose, so there is no pre-reboot baseline to defend
|
||||
against. The :data:`_baseline_floor` attribute is reset to
|
||||
``0.0`` so the AC-8 max() degenerates to plain inflation.
|
||||
"""
|
||||
self._inner.reset_to_warm_start(hint)
|
||||
self._post_reset_remaining = self._max_frames
|
||||
self._baseline_floor = 0.0
|
||||
self._frames_since_save = 0
|
||||
|
||||
def prime_post_reboot(self, loaded: LoadedWarmStartHint) -> None:
|
||||
"""Wrapper extension: F8 reboot path, installs the AC-8 floor.
|
||||
|
||||
Forwards the loaded pose to the inner strategy via
|
||||
:meth:`reset_to_warm_start`, then arms the inflation window
|
||||
AND captures ``loaded.pre_reboot_covariance_norm`` as the
|
||||
floor that subsequent :meth:`process_frame` calls must
|
||||
respect for ``warm_start_max_frames`` frames.
|
||||
|
||||
NOT a Protocol method — the autodev-injected F8 path calls
|
||||
this directly on a :class:`WarmStartWiredStrategy` instance.
|
||||
"""
|
||||
self._inner.reset_to_warm_start(loaded.pose)
|
||||
self._post_reset_remaining = self._max_frames
|
||||
self._baseline_floor = float(loaded.pre_reboot_covariance_norm)
|
||||
self._frames_since_save = 0
|
||||
|
||||
def health_snapshot(self) -> VioHealth:
|
||||
"""Forward unchanged — health is a strategy concern, not a wiring concern."""
|
||||
return self._inner.health_snapshot()
|
||||
|
||||
def current_strategy_label(
|
||||
self,
|
||||
) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
|
||||
"""Forward unchanged so :class:`VioHealth.strategy_label` audit is honest."""
|
||||
return self._inner.current_strategy_label()
|
||||
|
||||
def _apply_post_reset_inflation(self, out: VioOutput) -> VioOutput:
|
||||
"""Inflate the emitted covariance by the configured factor + AC-8 floor.
|
||||
|
||||
AC-7: inflated norm = factor × strategy_emitted_norm. AC-8:
|
||||
further scale up so inflated norm ≥ ``_baseline_floor``. Both
|
||||
scalings preserve symmetry and positive-definiteness because
|
||||
they are pure positive scalar multiplications of the SPD
|
||||
matrix (eigenvalues stay strictly positive).
|
||||
"""
|
||||
original = np.asarray(out.pose_covariance_6x6, dtype=np.float64)
|
||||
inflated = original * self._inflation_factor
|
||||
inflated_norm = float(np.linalg.norm(inflated, ord="fro"))
|
||||
if (
|
||||
self._baseline_floor > 0.0
|
||||
and inflated_norm > 0.0
|
||||
and inflated_norm < self._baseline_floor
|
||||
):
|
||||
scale = self._baseline_floor / inflated_norm
|
||||
inflated = inflated * scale
|
||||
return replace(out, pose_covariance_6x6=inflated)
|
||||
|
||||
def _save_hint_from_output(self, out: VioOutput) -> None:
|
||||
"""Construct a :class:`WarmStartPose` from the last emitted output and save.
|
||||
|
||||
``velocity_b`` is left at zero — the wrapper has no velocity
|
||||
source on the per-frame save path (the strategy's
|
||||
:class:`VioOutput` does not expose velocity, and chasing it
|
||||
would require a numerical-differentiation sidecar that
|
||||
belongs in a future cycle). On F8 reload the strategy
|
||||
re-estimates velocity from its IMU integration, so a
|
||||
zero-velocity hint is acceptable for the recovery path.
|
||||
|
||||
Per-frame save failures do NOT propagate — they are logged
|
||||
at ERROR and swallowed (AC-NFR-no-crash). The hint store
|
||||
will be in whatever state the failed atomic-write left it
|
||||
(the AZ-280 contract guarantees no half-written file).
|
||||
"""
|
||||
hint = WarmStartPose(
|
||||
body_T_world=out.relative_pose_T,
|
||||
velocity_b=(0.0, 0.0, 0.0),
|
||||
bias=out.imu_bias,
|
||||
captured_at_ns=int(out.emitted_at_ns),
|
||||
)
|
||||
try:
|
||||
self._store.save(
|
||||
hint,
|
||||
pre_reboot_covariance_norm=self._last_emitted_covariance_norm,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._log.error(
|
||||
"warm-start hint save failed",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.save_failed",
|
||||
"kv": {
|
||||
"reason": str(exc),
|
||||
"frame_id": out.frame_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _emit_prime_fdr(
|
||||
*,
|
||||
fdr_client: "FdrClient",
|
||||
source: str,
|
||||
strategy_label: str,
|
||||
bias_norm_value: float | None,
|
||||
staleness_ns: int | None,
|
||||
pre_reboot_covariance_norm: float | None,
|
||||
) -> None:
|
||||
"""Emit the single AZ-335 ``vio.warm_start`` FDR record."""
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=now_iso(),
|
||||
producer_id=WARM_START_PRODUCER_ID,
|
||||
kind="vio.warm_start",
|
||||
payload={
|
||||
"source": source,
|
||||
"strategy_label": strategy_label,
|
||||
"bias_norm": bias_norm_value,
|
||||
"staleness_ns": staleness_ns,
|
||||
"pre_reboot_covariance_norm": pre_reboot_covariance_norm,
|
||||
},
|
||||
)
|
||||
fdr_client.enqueue(record)
|
||||
|
||||
|
||||
def _emit_prime_log(
|
||||
*,
|
||||
log: Any,
|
||||
level: str,
|
||||
msg: str,
|
||||
source: str,
|
||||
strategy_label: str,
|
||||
extra_kv: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Single emission point for prime-time INFO/WARN logs."""
|
||||
kv: dict[str, Any] = {
|
||||
"source": source,
|
||||
"strategy_label": strategy_label,
|
||||
}
|
||||
if extra_kv:
|
||||
kv.update(extra_kv)
|
||||
record_extra = {
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": f"c1.warm_start.{source}",
|
||||
"kv": kv,
|
||||
}
|
||||
if level == "warning":
|
||||
log.warning(msg, extra=record_extra)
|
||||
else:
|
||||
log.info(msg, extra=record_extra)
|
||||
|
||||
|
||||
def prime_warm_start_from_disk(
|
||||
strategy: WarmStartWiredStrategy,
|
||||
store: WarmStartHintStore,
|
||||
*,
|
||||
fdr_client: "FdrClient",
|
||||
) -> bool:
|
||||
"""F8 reboot prime hook — called at process startup before first ``process_frame``.
|
||||
|
||||
Reads the persisted hint via ``store.load()``:
|
||||
|
||||
- If a hint is loaded, calls :meth:`WarmStartWiredStrategy.prime_post_reboot`
|
||||
(which forwards to the inner strategy AND installs the AC-8 floor),
|
||||
emits one INFO log ``c1.warm_start.f8_reboot_disk``, and emits one
|
||||
FDR record ``vio.warm_start`` with ``source="f8_reboot_disk"``.
|
||||
- If ``store.load()`` returns ``None`` (cold start, corrupted file,
|
||||
calibration mismatch), emits one INFO log
|
||||
``c1.warm_start.cold_start_no_hint`` and one FDR record with
|
||||
``source="cold_start_no_hint"``. The strategy is left untouched
|
||||
and proceeds with its own INIT-state behaviour.
|
||||
|
||||
Returns ``True`` iff a hint was loaded AND applied. Never raises:
|
||||
a :class:`VioFatalError` from the inner strategy's
|
||||
:meth:`reset_to_warm_start` is caught, logged at ERROR
|
||||
(``c1.warm_start.reset_failed``), and the function returns
|
||||
``False`` so the camera ingest can still start in cold-start mode.
|
||||
"""
|
||||
log = get_logger(_LOGGER_NAME)
|
||||
strategy_label = strategy.current_strategy_label()
|
||||
loaded = store.load()
|
||||
if loaded is None:
|
||||
_emit_prime_log(
|
||||
log=log,
|
||||
level="info",
|
||||
msg="warm-start cold start — no prior hint",
|
||||
source=_SOURCE_COLD_START,
|
||||
strategy_label=strategy_label,
|
||||
)
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_COLD_START,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=None,
|
||||
staleness_ns=None,
|
||||
pre_reboot_covariance_norm=None,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
strategy.prime_post_reboot(loaded)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"warm-start prime_post_reboot failed",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.reset_failed",
|
||||
"kv": {
|
||||
"source": _SOURCE_F8_REBOOT,
|
||||
"strategy_label": strategy_label,
|
||||
"reason": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
return False
|
||||
staleness_ns = max(0, int(time.monotonic_ns()) - int(loaded.pose.captured_at_ns))
|
||||
_emit_prime_log(
|
||||
log=log,
|
||||
level="info",
|
||||
msg="warm-start F8 reboot — hint loaded from disk",
|
||||
source=_SOURCE_F8_REBOOT,
|
||||
strategy_label=strategy_label,
|
||||
extra_kv={
|
||||
"staleness_ns": staleness_ns,
|
||||
"pre_reboot_covariance_norm": loaded.pre_reboot_covariance_norm,
|
||||
},
|
||||
)
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_F8_REBOOT,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=bias_norm(loaded.pose.bias),
|
||||
staleness_ns=staleness_ns,
|
||||
pre_reboot_covariance_norm=loaded.pre_reboot_covariance_norm,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def prime_warm_start_from_fc(
|
||||
strategy: WarmStartWiredStrategy,
|
||||
source: WarmStartFcSource,
|
||||
store: WarmStartHintStore,
|
||||
*,
|
||||
fdr_client: "FdrClient",
|
||||
) -> bool:
|
||||
"""F2 takeoff prime hook — called once on the ``IN_AIR`` flight-state edge.
|
||||
|
||||
Asks the consumer-side cut for the FC EKF's last valid pose:
|
||||
|
||||
- If a hint is returned, calls :meth:`WarmStartWiredStrategy.reset_to_warm_start`
|
||||
(the inflation window arms WITHOUT an AC-8 floor — there is no
|
||||
pre-reboot baseline on the F2 path because the FC just provided
|
||||
a fresh pose), persists the same hint via ``store.save`` so the
|
||||
next F8 reboot can recover from it, and emits the INFO log +
|
||||
FDR record with ``source="f2_takeoff_fc"``.
|
||||
- If the source returns ``None`` or raises, emits one WARN log
|
||||
``c1.warm_start.f2_takeoff_fc_unavailable`` and an FDR record
|
||||
with ``source="cold_start_no_hint"``; the strategy is left in
|
||||
its current state and the camera ingest proceeds (AC-NFR-no-crash).
|
||||
|
||||
Returns ``True`` iff a hint was fetched, applied, AND persisted.
|
||||
Never raises.
|
||||
"""
|
||||
log = get_logger(_LOGGER_NAME)
|
||||
strategy_label = strategy.current_strategy_label()
|
||||
try:
|
||||
hint = source.fetch_warm_start_pose()
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"warm-start FC fetch raised",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.f2_takeoff_fc_unavailable",
|
||||
"kv": {
|
||||
"source": _SOURCE_F2_TAKEOFF,
|
||||
"strategy_label": strategy_label,
|
||||
"reason": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_COLD_START,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=None,
|
||||
staleness_ns=None,
|
||||
pre_reboot_covariance_norm=None,
|
||||
)
|
||||
return False
|
||||
if hint is None:
|
||||
log.warning(
|
||||
"warm-start FC has no valid pose yet",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.f2_takeoff_fc_unavailable",
|
||||
"kv": {
|
||||
"source": _SOURCE_F2_TAKEOFF,
|
||||
"strategy_label": strategy_label,
|
||||
"reason": "fc_returned_none",
|
||||
},
|
||||
},
|
||||
)
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_COLD_START,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=None,
|
||||
staleness_ns=None,
|
||||
pre_reboot_covariance_norm=None,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
strategy.reset_to_warm_start(hint)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"warm-start F2 reset_to_warm_start failed",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.reset_failed",
|
||||
"kv": {
|
||||
"source": _SOURCE_F2_TAKEOFF,
|
||||
"strategy_label": strategy_label,
|
||||
"reason": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
return False
|
||||
try:
|
||||
store.save(hint, pre_reboot_covariance_norm=0.0)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
log.error(
|
||||
"warm-start F2 persist failed",
|
||||
extra={
|
||||
"component": _LOGGER_COMPONENT,
|
||||
"kind": "c1.warm_start.save_failed",
|
||||
"kv": {
|
||||
"source": _SOURCE_F2_TAKEOFF,
|
||||
"strategy_label": strategy_label,
|
||||
"reason": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
# the strategy already accepted the hint; the FDR record
|
||||
# below still records the F2 prime for audit, but we return
|
||||
# False to indicate persistence did not complete. The next
|
||||
# successful per-frame save will restore the on-disk state.
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_F2_TAKEOFF,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=bias_norm(hint.bias),
|
||||
staleness_ns=None,
|
||||
pre_reboot_covariance_norm=None,
|
||||
)
|
||||
return False
|
||||
_emit_prime_log(
|
||||
log=log,
|
||||
level="info",
|
||||
msg="warm-start F2 takeoff — hint primed from FC",
|
||||
source=_SOURCE_F2_TAKEOFF,
|
||||
strategy_label=strategy_label,
|
||||
)
|
||||
_emit_prime_fdr(
|
||||
fdr_client=fdr_client,
|
||||
source=_SOURCE_F2_TAKEOFF,
|
||||
strategy_label=strategy_label,
|
||||
bias_norm_value=bias_norm(hint.bias),
|
||||
staleness_ns=None,
|
||||
pre_reboot_covariance_norm=None,
|
||||
)
|
||||
return True
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Shared fixtures for ``tests/unit/c1_vio/`` (AZ-332).
|
||||
"""Shared fixtures for ``tests/unit/c1_vio/`` (AZ-332 + AZ-333).
|
||||
|
||||
Provides a scriptable fake ``okvis2_binding`` module installed at the
|
||||
``sys.modules`` boundary BEFORE the strategy's lazy import inside the
|
||||
constructor runs. The fake mirrors the real binding's surface
|
||||
(``Okvis2Backend`` class + 3 exception types) so :class:`Okvis2Strategy`
|
||||
can be exercised on macOS dev + GitHub Actions Linux runner without
|
||||
the real OKVIS2 / pybind11 native lib.
|
||||
Provides scriptable fake binding modules installed at the
|
||||
``sys.modules`` boundary BEFORE each strategy's lazy import inside the
|
||||
constructor runs. Each fake mirrors its real binding's surface
|
||||
(``Okvis2Backend`` / ``VinsMonoBackend`` class + 3 exception types)
|
||||
so the Python facades can be exercised on macOS dev + GitHub Actions
|
||||
Linux runner without the real OKVIS2 / VINS-Mono / pybind11 native
|
||||
libs.
|
||||
|
||||
The task spec explicitly permits this for AC-3, AC-6, AC-7 backend-
|
||||
Each task spec explicitly permits this for AC-3, AC-6, AC-7 backend-
|
||||
exception injection (and by extension the rest of the AC suite that
|
||||
exercises the Python facade only).
|
||||
exercises the Python facade only). The :class:`FakeOkvis2Backend` and
|
||||
:class:`FakeVinsMonoBackend` classes share the same scripted-output
|
||||
shape (:class:`ScriptedOutput`) because the AZ-331 Protocol forces
|
||||
both strategies to surface the same payload contract — keeping the
|
||||
fakes shape-compatible cuts duplication and makes the IT-12
|
||||
comparative harness trivially substitutable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +31,12 @@ import pytest
|
||||
|
||||
_BINDING_MODULE_NAME: Final[str] = "gps_denied_onboard.components.c1_vio._native.okvis2_binding"
|
||||
_STRATEGY_MODULE_NAME: Final[str] = "gps_denied_onboard.components.c1_vio.okvis2"
|
||||
_VINS_BINDING_MODULE_NAME: Final[str] = (
|
||||
"gps_denied_onboard.components.c1_vio._native.vins_mono_binding"
|
||||
)
|
||||
_VINS_STRATEGY_MODULE_NAME: Final[str] = (
|
||||
"gps_denied_onboard.components.c1_vio.vins_mono"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -185,3 +197,119 @@ def fake_okvis2_binding(
|
||||
yield FakeOkvis2Backend
|
||||
|
||||
sys.modules.pop(_STRATEGY_MODULE_NAME, None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AZ-333 — VINS-Mono fake binding + fixture (mirrors the OKVIS2 pattern).
|
||||
# Shape-compatible with FakeOkvis2Backend so the IT-12 comparative
|
||||
# harness can drive both strategies through the same ScriptedOutput
|
||||
# pipeline.
|
||||
|
||||
|
||||
class FakeVinsMonoInitException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FakeVinsMonoFatalException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FakeVinsMonoOptimizationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FakeVinsMonoBackend:
|
||||
def __init__(
|
||||
self,
|
||||
yaml_config: str,
|
||||
camera_intrinsics_3x3: np.ndarray,
|
||||
) -> None:
|
||||
self.yaml_config = yaml_config
|
||||
self.camera_intrinsics_3x3 = np.asarray(camera_intrinsics_3x3, dtype=np.float64)
|
||||
self._scripted: deque[ScriptedOutput] = deque()
|
||||
self._latest: dict[str, Any] | None = None
|
||||
self._frames_seen: list[tuple[str, int]] = []
|
||||
self._imu_samples: list[tuple[int, np.ndarray, np.ndarray]] = []
|
||||
self._reset_calls: int = 0
|
||||
self._health: dict[str, Any] = {
|
||||
"state": "init",
|
||||
"consecutive_lost": 0,
|
||||
"bias_norm": 0.0,
|
||||
}
|
||||
|
||||
def script(self, *outputs: ScriptedOutput) -> None:
|
||||
self._scripted.extend(outputs)
|
||||
|
||||
def add_frame(self, frame_id: str, ts_ns: int, image: np.ndarray) -> bool:
|
||||
self._frames_seen.append((frame_id, ts_ns))
|
||||
if not self._scripted:
|
||||
self._latest = _make_default_payload(frame_id)
|
||||
return True
|
||||
head = self._scripted.popleft()
|
||||
if head.raise_with is not None:
|
||||
raise head.raise_with
|
||||
if head.produced:
|
||||
payload = dict(_make_default_payload(frame_id))
|
||||
payload.update(head.payload)
|
||||
payload["frame_id"] = frame_id
|
||||
self._latest = payload
|
||||
return head.produced
|
||||
|
||||
def add_imu(self, ts_ns: int, accel: np.ndarray, gyro: np.ndarray) -> None:
|
||||
self._imu_samples.append((ts_ns, np.asarray(accel), np.asarray(gyro)))
|
||||
|
||||
def get_latest_output(self) -> dict[str, Any] | None:
|
||||
return self._latest
|
||||
|
||||
def reset(
|
||||
self,
|
||||
body_T_world: np.ndarray,
|
||||
velocity: np.ndarray,
|
||||
accel_bias: np.ndarray,
|
||||
gyro_bias: np.ndarray,
|
||||
) -> None:
|
||||
self._reset_calls += 1
|
||||
self._latest = None
|
||||
self._health["state"] = "init"
|
||||
self._health["consecutive_lost"] = 0
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return dict(self._health)
|
||||
|
||||
@property
|
||||
def frames_seen(self) -> list[tuple[str, int]]:
|
||||
return list(self._frames_seen)
|
||||
|
||||
@property
|
||||
def reset_call_count(self) -> int:
|
||||
return self._reset_calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_vins_mono_binding(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> Iterator[type[FakeVinsMonoBackend]]:
|
||||
"""Install a fake ``vins_mono_binding`` module at the import boundary.
|
||||
|
||||
Cleans up both the binding module and the strategy module so each
|
||||
test starts with a fresh lazy-import state. Mirrors
|
||||
:func:`fake_okvis2_binding` exactly because the two strategies are
|
||||
drop-in substitutable via the AZ-331 factory.
|
||||
"""
|
||||
import types
|
||||
|
||||
fake_module = types.ModuleType(_VINS_BINDING_MODULE_NAME)
|
||||
fake_module.VinsMonoBackend = FakeVinsMonoBackend # type: ignore[attr-defined]
|
||||
fake_module.VinsMonoInitException = FakeVinsMonoInitException # type: ignore[attr-defined]
|
||||
fake_module.VinsMonoFatalException = FakeVinsMonoFatalException # type: ignore[attr-defined]
|
||||
fake_module.VinsMonoOptimizationException = ( # type: ignore[attr-defined]
|
||||
FakeVinsMonoOptimizationException
|
||||
)
|
||||
|
||||
sys.modules.pop(_VINS_BINDING_MODULE_NAME, None)
|
||||
sys.modules.pop(_VINS_STRATEGY_MODULE_NAME, None)
|
||||
monkeypatch.setitem(sys.modules, _VINS_BINDING_MODULE_NAME, fake_module)
|
||||
|
||||
yield FakeVinsMonoBackend
|
||||
|
||||
sys.modules.pop(_VINS_STRATEGY_MODULE_NAME, None)
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
"""AZ-335 — C1 warm-start hint persistence + F8 reboot recovery wiring tests.
|
||||
|
||||
Covers all 10 acceptance criteria from
|
||||
``_docs/02_tasks/todo/AZ-335_c1_warm_start_recovery.md`` plus three
|
||||
non-functional requirements (perf-save, perf-load, no-crash). Tests
|
||||
target both the c1-internal :class:`JsonSidecarWarmStartHintStore`
|
||||
and the runtime-root :class:`WarmStartWiredStrategy` + prime hooks.
|
||||
|
||||
The wiring tests construct a deliberately minimal scriptable
|
||||
:class:`_FakeVioStrategy` (kept local — the c1_vio strategy backends
|
||||
already exercise the strategy-internal Protocol shape exhaustively;
|
||||
this file's job is to verify the **wiring** layer behaves correctly
|
||||
when wrapped around any strategy). The store tests use the real
|
||||
:class:`Sha256Sidecar` (atomicwrites) on tmp_path — no fakes here
|
||||
because the AC-1/AC-2/AC-10 contracts ARE about the on-disk
|
||||
behaviour itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import gtsam
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.nav import (
|
||||
FeatureQuality,
|
||||
ImuBias,
|
||||
ImuWindow,
|
||||
NavCameraFrame,
|
||||
VioHealth,
|
||||
VioOutput,
|
||||
VioState,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard.components.c1_vio.warm_start_store import (
|
||||
HINT_FILENAME,
|
||||
HINT_SCHEMA_VERSION,
|
||||
JsonSidecarWarmStartHintStore,
|
||||
LoadedWarmStartHint,
|
||||
WarmStartFcSource,
|
||||
WarmStartHintStore,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import SIDECAR_SUFFIX
|
||||
from gps_denied_onboard.runtime_root.warm_start_wiring import (
|
||||
WARM_START_PRODUCER_ID,
|
||||
WarmStartWiredStrategy,
|
||||
prime_warm_start_from_disk,
|
||||
prime_warm_start_from_fc,
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_CALIBRATION_ID = "adti26"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared builders.
|
||||
|
||||
|
||||
def _make_pose(yaw_deg: float = 0.0, x: float = 1.0, y: float = 2.0, z: float = 3.0) -> gtsam.Pose3:
|
||||
"""A concrete SE(3) pose with a deterministic, non-identity rotation."""
|
||||
yaw = np.deg2rad(yaw_deg)
|
||||
R = np.array(
|
||||
[
|
||||
[np.cos(yaw), -np.sin(yaw), 0.0],
|
||||
[np.sin(yaw), np.cos(yaw), 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
T = np.eye(4, dtype=np.float64)
|
||||
T[:3, :3] = R
|
||||
T[:3, 3] = [x, y, z]
|
||||
return gtsam.Pose3(T)
|
||||
|
||||
|
||||
def _make_hint(
|
||||
*,
|
||||
yaw_deg: float = 5.0,
|
||||
velocity: tuple[float, float, float] = (1.0, 2.0, 3.0),
|
||||
accel_bias: tuple[float, float, float] = (0.01, -0.02, 0.03),
|
||||
gyro_bias: tuple[float, float, float] = (0.001, 0.002, -0.003),
|
||||
captured_at_ns: int = 1_700_000_000_000,
|
||||
) -> WarmStartPose:
|
||||
return WarmStartPose(
|
||||
body_T_world=_make_pose(yaw_deg=yaw_deg),
|
||||
velocity_b=velocity,
|
||||
bias=ImuBias(accel_bias=accel_bias, gyro_bias=gyro_bias),
|
||||
captured_at_ns=captured_at_ns,
|
||||
)
|
||||
|
||||
|
||||
def _make_calibration() -> CameraCalibration:
|
||||
return CameraCalibration(
|
||||
camera_id="cam0",
|
||||
intrinsics_3x3=np.eye(3, dtype=np.float64),
|
||||
distortion=np.zeros(4, dtype=np.float64),
|
||||
body_to_camera_se3=_make_pose(),
|
||||
acquisition_method="checker_board",
|
||||
)
|
||||
|
||||
|
||||
def _make_imu_window() -> ImuWindow:
|
||||
return ImuWindow(samples=tuple(), ts_start_ns=0, ts_end_ns=0)
|
||||
|
||||
|
||||
def _make_frame(frame_id: int = 1) -> NavCameraFrame:
|
||||
return NavCameraFrame(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime(2026, 5, 14, 0, 0, 0, tzinfo=timezone.utc),
|
||||
image=np.zeros((10, 10), dtype=np.uint8),
|
||||
camera_calibration_id="cam0",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local scriptable VioStrategy fake — wiring tests only.
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ResetCall:
|
||||
"""One captured ``reset_to_warm_start`` invocation on the fake strategy."""
|
||||
|
||||
hint: WarmStartPose
|
||||
|
||||
|
||||
class _FakeVioStrategy:
|
||||
"""Scriptable minimal :class:`VioStrategy` for AZ-335 wiring tests.
|
||||
|
||||
Returns a deterministic per-call :class:`VioOutput` whose
|
||||
``pose_covariance_6x6`` is the value most recently set via
|
||||
:meth:`set_emit_covariance` (default ``np.eye(6) * 0.01``).
|
||||
Each :meth:`reset_to_warm_start` invocation is captured in
|
||||
:attr:`reset_calls` so wiring tests can assert single-call,
|
||||
correct-hint, no-call semantics.
|
||||
"""
|
||||
|
||||
def __init__(self, *, label: Literal["okvis2", "vins_mono", "klt_ransac"] = "klt_ransac") -> None:
|
||||
self._label = label
|
||||
self._next_cov = np.eye(6, dtype=np.float64) * 0.01
|
||||
self._next_bias = ImuBias(accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0))
|
||||
self._frame_counter = 0
|
||||
self.reset_calls: list[_ResetCall] = []
|
||||
self._raise_on_reset: Exception | None = None
|
||||
|
||||
def set_emit_covariance(self, cov: np.ndarray) -> None:
|
||||
self._next_cov = np.asarray(cov, dtype=np.float64)
|
||||
|
||||
def set_emit_bias(self, bias: ImuBias) -> None:
|
||||
self._next_bias = bias
|
||||
|
||||
def script_reset_failure(self, exc: Exception) -> None:
|
||||
self._raise_on_reset = exc
|
||||
|
||||
def process_frame(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
imu: ImuWindow,
|
||||
calibration: CameraCalibration,
|
||||
) -> VioOutput:
|
||||
self._frame_counter += 1
|
||||
return VioOutput(
|
||||
frame_id=f"frame-{self._frame_counter}",
|
||||
relative_pose_T=_make_pose(),
|
||||
pose_covariance_6x6=self._next_cov.copy(),
|
||||
imu_bias=self._next_bias,
|
||||
feature_quality=FeatureQuality(
|
||||
tracked=80, new=2, lost=1, mean_parallax=5.0, mre_px=0.8
|
||||
),
|
||||
emitted_at_ns=1_700_000_000_000 + self._frame_counter,
|
||||
)
|
||||
|
||||
def reset_to_warm_start(self, hint: WarmStartPose) -> None:
|
||||
if self._raise_on_reset is not None:
|
||||
raise self._raise_on_reset
|
||||
self.reset_calls.append(_ResetCall(hint=hint))
|
||||
|
||||
def health_snapshot(self) -> VioHealth:
|
||||
return VioHealth(state=VioState.TRACKING, consecutive_lost=0, bias_norm=0.0)
|
||||
|
||||
def current_strategy_label(self) -> Literal["okvis2", "vins_mono", "klt_ransac"]:
|
||||
return self._label
|
||||
|
||||
|
||||
class _FakeFcSource:
|
||||
"""Scriptable :class:`WarmStartFcSource` for F2 takeoff tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
hint: WarmStartPose | None = None,
|
||||
raise_with: Exception | None = None,
|
||||
calibration_id: str = _DEFAULT_CALIBRATION_ID,
|
||||
) -> None:
|
||||
self._hint = hint
|
||||
self._raise_with = raise_with
|
||||
self._calibration_id = calibration_id
|
||||
self.fetch_call_count = 0
|
||||
|
||||
def fetch_warm_start_pose(self) -> WarmStartPose | None:
|
||||
self.fetch_call_count += 1
|
||||
if self._raise_with is not None:
|
||||
raise self._raise_with
|
||||
return self._hint
|
||||
|
||||
def calibration_id(self) -> str:
|
||||
return self._calibration_id
|
||||
|
||||
|
||||
def _make_wired(
|
||||
inner: _FakeVioStrategy,
|
||||
store: WarmStartHintStore,
|
||||
*,
|
||||
warm_start_max_frames: int = 5,
|
||||
inflation_factor: float = 2.0,
|
||||
save_period: int = 5,
|
||||
) -> WarmStartWiredStrategy:
|
||||
return WarmStartWiredStrategy(
|
||||
inner=inner,
|
||||
store=store,
|
||||
warm_start_max_frames=warm_start_max_frames,
|
||||
post_reset_covariance_inflation_factor=inflation_factor,
|
||||
warm_start_save_period_frames=save_period,
|
||||
)
|
||||
|
||||
|
||||
def _drive_frames(wired: WarmStartWiredStrategy, n: int) -> list[VioOutput]:
|
||||
return [
|
||||
wired.process_frame(_make_frame(i), _make_imu_window(), _make_calibration())
|
||||
for i in range(1, n + 1)
|
||||
]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Store tests — AC-1, AC-2, AC-9, AC-10, NFR-perf-save, NFR-perf-load,
|
||||
# Risk-2 calibration-mismatch.
|
||||
|
||||
|
||||
class TestStoreAc1RoundTrip:
|
||||
def test_save_then_load_returns_deep_equal_hint(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
hint = _make_hint()
|
||||
|
||||
# Act
|
||||
store.save(hint, pre_reboot_covariance_norm=0.123)
|
||||
loaded = store.load()
|
||||
|
||||
# Assert
|
||||
assert loaded is not None
|
||||
assert isinstance(loaded, LoadedWarmStartHint)
|
||||
assert loaded.calibration_id == _DEFAULT_CALIBRATION_ID
|
||||
assert loaded.pre_reboot_covariance_norm == pytest.approx(0.123)
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.pose.body_T_world.matrix(), hint.body_T_world.matrix()
|
||||
)
|
||||
assert loaded.pose.velocity_b == hint.velocity_b
|
||||
assert loaded.pose.bias == hint.bias
|
||||
assert loaded.pose.captured_at_ns == hint.captured_at_ns
|
||||
|
||||
def test_save_creates_payload_and_sidecar_files(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
hint = _make_hint()
|
||||
|
||||
# Act
|
||||
store.save(hint, pre_reboot_covariance_norm=0.5)
|
||||
|
||||
# Assert
|
||||
assert (tmp_path / HINT_FILENAME).exists()
|
||||
assert (tmp_path / (HINT_FILENAME + SIDECAR_SUFFIX)).exists()
|
||||
assert store.payload_path == tmp_path / HINT_FILENAME
|
||||
|
||||
def test_save_creates_missing_parent_directory(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
nested = tmp_path / "nested" / "dirs" / "warm_start"
|
||||
store = JsonSidecarWarmStartHintStore(nested, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
hint = _make_hint()
|
||||
|
||||
# Act
|
||||
store.save(hint, pre_reboot_covariance_norm=0.0)
|
||||
|
||||
# Assert
|
||||
assert (nested / HINT_FILENAME).exists()
|
||||
|
||||
|
||||
class TestStoreAc2Corrupted:
|
||||
def _seed_valid_then_flip_one_byte(
|
||||
self, tmp_path: Path
|
||||
) -> tuple[JsonSidecarWarmStartHintStore, Path]:
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=0.1)
|
||||
payload_path = tmp_path / HINT_FILENAME
|
||||
original = payload_path.read_bytes()
|
||||
# Flip one byte mid-payload to trigger sha256 mismatch but keep
|
||||
# the file structurally present and the sidecar untouched.
|
||||
idx = len(original) // 2
|
||||
corrupted = original[:idx] + bytes([(original[idx] + 1) % 256]) + original[idx + 1 :]
|
||||
payload_path.write_bytes(corrupted)
|
||||
return store, payload_path
|
||||
|
||||
def test_corrupted_payload_returns_none(self, tmp_path: Path, caplog: Any) -> None:
|
||||
# Arrange
|
||||
store, _ = self._seed_valid_then_flip_one_byte(tmp_path)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
loaded = store.load()
|
||||
|
||||
# Assert
|
||||
assert loaded is None
|
||||
warn_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", "") == "c1.warm_start.corrupted"
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
assert warn_records[0].levelname == "WARNING"
|
||||
|
||||
def test_corrupted_file_is_not_silently_deleted(self, tmp_path: Path) -> None:
|
||||
# Arrange + Act
|
||||
store, payload_path = self._seed_valid_then_flip_one_byte(tmp_path)
|
||||
_ = store.load()
|
||||
|
||||
# Assert
|
||||
assert payload_path.exists(), "AC-2: operator may want to forensically inspect"
|
||||
|
||||
def test_structurally_invalid_json_returns_none_with_warn(
|
||||
self, tmp_path: Path, caplog: Any
|
||||
) -> None:
|
||||
# Arrange — write a payload with the WRONG schema version and rebuild the sidecar
|
||||
# so sha256 verifies but envelope deserialisation rejects.
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import Sha256Sidecar
|
||||
|
||||
bad_payload = b'{"version": 999, "calibration_id": "x", "pose": {}}'
|
||||
Sha256Sidecar.write_atomic_and_sidecar(tmp_path / HINT_FILENAME, bad_payload)
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id="x")
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
loaded = store.load()
|
||||
|
||||
# Assert
|
||||
assert loaded is None
|
||||
kinds = [getattr(r, "kind", "") for r in caplog.records]
|
||||
assert "c1.warm_start.corrupted" in kinds
|
||||
|
||||
|
||||
class TestStoreAc3CalibrationMismatch:
|
||||
def test_calibration_mismatch_returns_none_with_specific_warn(
|
||||
self, tmp_path: Path, caplog: Any
|
||||
) -> None:
|
||||
# Arrange
|
||||
producer = JsonSidecarWarmStartHintStore(tmp_path, calibration_id="OLD_CAL")
|
||||
producer.save(_make_hint(), pre_reboot_covariance_norm=0.1)
|
||||
consumer = JsonSidecarWarmStartHintStore(tmp_path, calibration_id="NEW_CAL")
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
loaded = consumer.load()
|
||||
|
||||
# Assert
|
||||
assert loaded is None
|
||||
warn_records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "kind", "") == "c1.warm_start.calibration_mismatch"
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
|
||||
|
||||
class TestStoreAc9Clear:
|
||||
def test_clear_removes_payload_and_sidecar(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=0.1)
|
||||
|
||||
# Act
|
||||
store.clear()
|
||||
|
||||
# Assert
|
||||
assert not (tmp_path / HINT_FILENAME).exists()
|
||||
assert not (tmp_path / (HINT_FILENAME + SIDECAR_SUFFIX)).exists()
|
||||
assert store.load() is None
|
||||
|
||||
def test_clear_emits_info_log(self, tmp_path: Path, caplog: Any) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO):
|
||||
store.clear()
|
||||
|
||||
# Assert
|
||||
info_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", "") == "c1.warm_start.cleared"
|
||||
]
|
||||
assert len(info_records) == 1
|
||||
assert info_records[0].levelname == "INFO"
|
||||
|
||||
def test_clear_is_idempotent(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
|
||||
# Act + Assert — first clear with no files MUST NOT raise
|
||||
store.clear()
|
||||
store.clear()
|
||||
|
||||
|
||||
class TestStoreAc10Atomicity:
|
||||
def test_kill_mid_save_leaves_prior_hint_loadable(self, tmp_path: Path) -> None:
|
||||
"""Simulate a crash mid-save by writing a temp file but never renaming.
|
||||
|
||||
``Sha256Sidecar.write_atomic_and_sidecar`` uses
|
||||
``atomicwrites.atomic_write`` (temp-file + ``os.replace``), so
|
||||
a mid-write crash never leaves a partial `c1_warm_start.json`.
|
||||
We model the "process killed mid-save" scenario by leaving a
|
||||
stray temp file alongside an already-committed prior hint;
|
||||
:meth:`load` must still return the prior valid hint.
|
||||
"""
|
||||
# Arrange — first save commits a known hint
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
prior = _make_hint(yaw_deg=0.0)
|
||||
store.save(prior, pre_reboot_covariance_norm=0.1)
|
||||
|
||||
# Simulate a half-written temp file from a "killed" second save.
|
||||
# atomicwrites uses a temp file with a `.<name>.<rand>` prefix.
|
||||
stray = tmp_path / f".{HINT_FILENAME}.partial-write-stray"
|
||||
stray.write_bytes(b"this-is-half-written-junk")
|
||||
|
||||
# Act
|
||||
loaded = store.load()
|
||||
|
||||
# Assert — the prior valid hint loads despite the stray temp file.
|
||||
assert loaded is not None
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.pose.body_T_world.matrix(), prior.body_T_world.matrix()
|
||||
)
|
||||
# The stray file was NOT consumed as the hint.
|
||||
assert stray.exists()
|
||||
|
||||
|
||||
class TestStoreLifecycle:
|
||||
def test_load_returns_none_when_no_file(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
|
||||
# Act + Assert
|
||||
assert store.load() is None
|
||||
|
||||
def test_default_impl_satisfies_protocol(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
|
||||
# Assert — runtime_checkable Protocol conformance
|
||||
assert isinstance(store, WarmStartHintStore)
|
||||
|
||||
|
||||
class TestStoreNfrPerf:
|
||||
def test_nfr_perf_save_p99_under_50ms(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
hint = _make_hint()
|
||||
n = 200 # bounded — full perf bench lives in C1-PT-01 Tier-2
|
||||
|
||||
# Act
|
||||
timings_ms = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
store.save(hint, pre_reboot_covariance_norm=0.1)
|
||||
timings_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
# Assert — p99 under 50ms; this is a smoke-budget on dev hardware,
|
||||
# the production budget is on Tier-2 NVMe per the task NFR.
|
||||
p99 = float(np.percentile(timings_ms, 99))
|
||||
assert p99 < 50.0, f"save p99 = {p99:.2f}ms exceeds 50ms NFR budget"
|
||||
|
||||
def test_nfr_perf_load_p99_under_20ms(self, tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
store = JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=0.1)
|
||||
n = 200
|
||||
|
||||
# Act
|
||||
timings_ms = []
|
||||
for _ in range(n):
|
||||
t0 = time.perf_counter()
|
||||
loaded = store.load()
|
||||
timings_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||
assert loaded is not None # sanity
|
||||
|
||||
# Assert
|
||||
p99 = float(np.percentile(timings_ms, 99))
|
||||
assert p99 < 20.0, f"load p99 = {p99:.2f}ms exceeds 20ms NFR budget"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Wiring tests — AC-3 .. AC-8, NFR-no-crash.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fdr_sink() -> FakeFdrSink:
|
||||
return FakeFdrSink(producer_id=WARM_START_PRODUCER_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_strategy() -> _FakeVioStrategy:
|
||||
return _FakeVioStrategy()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> JsonSidecarWarmStartHintStore:
|
||||
return JsonSidecarWarmStartHintStore(tmp_path, calibration_id=_DEFAULT_CALIBRATION_ID)
|
||||
|
||||
|
||||
class TestWiringAc3ColdStart:
|
||||
def test_cold_start_does_not_invoke_reset(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO):
|
||||
applied = prime_warm_start_from_disk(wired, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert
|
||||
assert applied is False
|
||||
assert fake_strategy.reset_calls == []
|
||||
info_records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "kind", "") == "c1.warm_start.cold_start_no_hint"
|
||||
]
|
||||
assert len(info_records) == 1
|
||||
cold_records = [r for r in fdr_sink.records if r.kind == "vio.warm_start"]
|
||||
assert len(cold_records) == 1
|
||||
assert cold_records[0].payload["source"] == "cold_start_no_hint"
|
||||
assert cold_records[0].payload["bias_norm"] is None
|
||||
|
||||
|
||||
class TestWiringAc4F8Reboot:
|
||||
def test_f8_reboot_loads_hint_calls_reset_emits_fdr(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange — seed a hint on disk
|
||||
prior_hint = _make_hint(yaw_deg=10.0)
|
||||
store.save(prior_hint, pre_reboot_covariance_norm=0.0625)
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO):
|
||||
applied = prime_warm_start_from_disk(wired, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert
|
||||
assert applied is True
|
||||
assert len(fake_strategy.reset_calls) == 1
|
||||
np.testing.assert_array_almost_equal(
|
||||
fake_strategy.reset_calls[0].hint.body_T_world.matrix(),
|
||||
prior_hint.body_T_world.matrix(),
|
||||
)
|
||||
# AC-8 baseline floor installed
|
||||
assert wired.baseline_floor == pytest.approx(0.0625)
|
||||
info_records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "kind", "") == "c1.warm_start.f8_reboot_disk"
|
||||
]
|
||||
assert len(info_records) == 1
|
||||
fdr_records = [r for r in fdr_sink.records if r.kind == "vio.warm_start"]
|
||||
assert len(fdr_records) == 1
|
||||
assert fdr_records[0].payload["source"] == "f8_reboot_disk"
|
||||
assert fdr_records[0].payload["pre_reboot_covariance_norm"] == pytest.approx(0.0625)
|
||||
assert fdr_records[0].payload["bias_norm"] is not None
|
||||
assert fdr_records[0].payload["staleness_ns"] is not None
|
||||
|
||||
|
||||
class TestWiringAc5F2Takeoff:
|
||||
def test_f2_takeoff_fetches_fc_calls_reset_persists(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange
|
||||
fc_hint = _make_hint(yaw_deg=20.0)
|
||||
source = _FakeFcSource(hint=fc_hint)
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO):
|
||||
applied = prime_warm_start_from_fc(wired, source, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert
|
||||
assert applied is True
|
||||
assert source.fetch_call_count == 1
|
||||
assert len(fake_strategy.reset_calls) == 1
|
||||
np.testing.assert_array_almost_equal(
|
||||
fake_strategy.reset_calls[0].hint.body_T_world.matrix(),
|
||||
fc_hint.body_T_world.matrix(),
|
||||
)
|
||||
# F2 path persists the hint so a subsequent F8 reboot can recover it.
|
||||
loaded = store.load()
|
||||
assert loaded is not None
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.pose.body_T_world.matrix(), fc_hint.body_T_world.matrix()
|
||||
)
|
||||
# AC-8 floor is NOT installed on the F2 path (no pre-reboot baseline).
|
||||
assert wired.baseline_floor == pytest.approx(0.0)
|
||||
info_records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "kind", "") == "c1.warm_start.f2_takeoff_fc"
|
||||
]
|
||||
assert len(info_records) == 1
|
||||
fdr_records = [r for r in fdr_sink.records if r.kind == "vio.warm_start"]
|
||||
assert len(fdr_records) == 1
|
||||
assert fdr_records[0].payload["source"] == "f2_takeoff_fc"
|
||||
|
||||
|
||||
class TestWiringAc6PerFrameSave:
|
||||
def test_per_frame_save_respects_period(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange — period = 5; 12 frames → save fires at frames 5 and 10 only
|
||||
wired = _make_wired(fake_strategy, store, save_period=5)
|
||||
|
||||
# Act
|
||||
outputs = _drive_frames(wired, 12)
|
||||
|
||||
# Assert
|
||||
assert len(outputs) == 12
|
||||
# The on-disk hint should reflect frame 10's emit, not frame 12's.
|
||||
loaded = store.load()
|
||||
assert loaded is not None
|
||||
assert loaded.pose.captured_at_ns == outputs[9].emitted_at_ns
|
||||
|
||||
def test_save_period_one_saves_every_frame(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
) -> None:
|
||||
# Arrange
|
||||
wired = _make_wired(fake_strategy, store, save_period=1)
|
||||
|
||||
# Act
|
||||
outputs = _drive_frames(wired, 3)
|
||||
|
||||
# Assert — last save reflects the most recent frame
|
||||
loaded = store.load()
|
||||
assert loaded is not None
|
||||
assert loaded.pose.captured_at_ns == outputs[-1].emitted_at_ns
|
||||
|
||||
|
||||
class TestWiringAc7PostResetInflation:
|
||||
def test_first_n_frames_inflated_then_unmodified(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
) -> None:
|
||||
# Arrange — strategy emits cov of Frobenius norm 1.0, factor=2.0,
|
||||
# window=5 frames. Save period large enough that no save fires
|
||||
# in the inflation window for cleaner assertion.
|
||||
emit_cov = np.eye(6, dtype=np.float64) * (1.0 / np.sqrt(6)) # ||·||_F = 1.0
|
||||
fake_strategy.set_emit_covariance(emit_cov)
|
||||
wired = _make_wired(
|
||||
fake_strategy,
|
||||
store,
|
||||
warm_start_max_frames=5,
|
||||
inflation_factor=2.0,
|
||||
save_period=100,
|
||||
)
|
||||
wired.reset_to_warm_start(_make_hint())
|
||||
|
||||
# Act — drive 6 frames; the first 5 inflated, the 6th unmodified.
|
||||
outputs = _drive_frames(wired, 6)
|
||||
|
||||
# Assert
|
||||
for i in range(5):
|
||||
norm = float(np.linalg.norm(outputs[i].pose_covariance_6x6, ord="fro"))
|
||||
assert norm == pytest.approx(2.0, abs=1e-9), (
|
||||
f"Frame {i + 1}: expected inflated norm 2.0, got {norm}"
|
||||
)
|
||||
norm6 = float(np.linalg.norm(outputs[5].pose_covariance_6x6, ord="fro"))
|
||||
assert norm6 == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
def test_no_inflation_when_no_reset_was_called(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
) -> None:
|
||||
# Arrange — the wrapper without any reset call should pass through.
|
||||
emit_cov = np.eye(6, dtype=np.float64) * (1.0 / np.sqrt(6))
|
||||
fake_strategy.set_emit_covariance(emit_cov)
|
||||
wired = _make_wired(
|
||||
fake_strategy, store, save_period=100, warm_start_max_frames=5, inflation_factor=2.0
|
||||
)
|
||||
|
||||
# Act
|
||||
out = wired.process_frame(_make_frame(), _make_imu_window(), _make_calibration())
|
||||
|
||||
# Assert
|
||||
norm = float(np.linalg.norm(out.pose_covariance_6x6, ord="fro"))
|
||||
assert norm == pytest.approx(1.0, abs=1e-9)
|
||||
|
||||
|
||||
class TestWiringAc8CovarianceFloor:
|
||||
def test_post_reboot_floor_enforced_above_inflation_alone(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
) -> None:
|
||||
# Arrange — pre-reboot baseline X = 5.0; strategy emits norm 1.0
|
||||
# so 2× inflation alone is only 2.0, well below X. Floor must
|
||||
# bump every output up to ≥ 5.0.
|
||||
baseline_x = 5.0
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=baseline_x)
|
||||
emit_cov = np.eye(6, dtype=np.float64) * (1.0 / np.sqrt(6))
|
||||
fake_strategy.set_emit_covariance(emit_cov)
|
||||
wired = _make_wired(
|
||||
fake_strategy,
|
||||
store,
|
||||
warm_start_max_frames=5,
|
||||
inflation_factor=2.0,
|
||||
save_period=100,
|
||||
)
|
||||
|
||||
# Act — F8 prime installs the floor, then 5 frames flow through
|
||||
applied = prime_warm_start_from_disk(wired, store, fdr_client=fdr_sink)
|
||||
assert applied is True
|
||||
outputs = _drive_frames(wired, 5)
|
||||
|
||||
# Assert — every post-reset frame's emitted norm ≥ X
|
||||
for i, out in enumerate(outputs):
|
||||
norm = float(np.linalg.norm(out.pose_covariance_6x6, ord="fro"))
|
||||
assert norm >= baseline_x - 1e-9, (
|
||||
f"AC-8 floor breached on frame {i + 1}: norm {norm} < baseline {baseline_x}"
|
||||
)
|
||||
|
||||
def test_post_reboot_floor_does_not_lower_when_inflation_alone_already_above(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
) -> None:
|
||||
# Arrange — baseline X = 0.5; strategy emits norm 1.0; inflation 2.0
|
||||
# alone gives 2.0 which already exceeds X. Floor must NOT scale down.
|
||||
baseline_x = 0.5
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=baseline_x)
|
||||
emit_cov = np.eye(6, dtype=np.float64) * (1.0 / np.sqrt(6))
|
||||
fake_strategy.set_emit_covariance(emit_cov)
|
||||
wired = _make_wired(fake_strategy, store, save_period=100)
|
||||
|
||||
# Act
|
||||
applied = prime_warm_start_from_disk(wired, store, fdr_client=fdr_sink)
|
||||
assert applied is True
|
||||
outputs = _drive_frames(wired, 1)
|
||||
|
||||
# Assert — norm is the inflated value (2.0), NOT the baseline (0.5)
|
||||
norm = float(np.linalg.norm(outputs[0].pose_covariance_6x6, ord="fro"))
|
||||
assert norm == pytest.approx(2.0, abs=1e-9)
|
||||
|
||||
|
||||
class TestWiringNfrNoCrash:
|
||||
def test_fc_source_raising_does_not_crash(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange
|
||||
source = _FakeFcSource(raise_with=RuntimeError("FC link down"))
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
applied = prime_warm_start_from_fc(wired, source, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert — degrades to cold-start; process keeps running
|
||||
assert applied is False
|
||||
assert fake_strategy.reset_calls == []
|
||||
warn_records = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if getattr(r, "kind", "") == "c1.warm_start.f2_takeoff_fc_unavailable"
|
||||
]
|
||||
assert len(warn_records) == 1
|
||||
|
||||
def test_fc_source_returning_none_does_not_crash(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange
|
||||
source = _FakeFcSource(hint=None)
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
applied = prime_warm_start_from_fc(wired, source, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert
|
||||
assert applied is False
|
||||
assert fake_strategy.reset_calls == []
|
||||
|
||||
def test_per_frame_save_failure_does_not_crash(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange — a store whose save always raises
|
||||
class _BoomStore:
|
||||
def save(self, hint: WarmStartPose, *, pre_reboot_covariance_norm: float) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
def load(self) -> LoadedWarmStartHint | None:
|
||||
return None
|
||||
|
||||
def clear(self) -> None:
|
||||
return None
|
||||
|
||||
wired = _make_wired(fake_strategy, _BoomStore(), save_period=1) # type: ignore[arg-type]
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR):
|
||||
out = wired.process_frame(_make_frame(), _make_imu_window(), _make_calibration())
|
||||
|
||||
# Assert — frame still emitted, error logged, no exception escapes
|
||||
assert out is not None
|
||||
err_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", "") == "c1.warm_start.save_failed"
|
||||
]
|
||||
assert len(err_records) == 1
|
||||
|
||||
def test_inner_strategy_reset_failure_does_not_crash_prime(
|
||||
self,
|
||||
fake_strategy: _FakeVioStrategy,
|
||||
store: JsonSidecarWarmStartHintStore,
|
||||
fdr_sink: FakeFdrSink,
|
||||
caplog: Any,
|
||||
) -> None:
|
||||
# Arrange
|
||||
store.save(_make_hint(), pre_reboot_covariance_norm=0.1)
|
||||
fake_strategy.script_reset_failure(RuntimeError("native bridge boom"))
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR):
|
||||
applied = prime_warm_start_from_disk(wired, store, fdr_client=fdr_sink)
|
||||
|
||||
# Assert
|
||||
assert applied is False
|
||||
err_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", "") == "c1.warm_start.reset_failed"
|
||||
]
|
||||
assert len(err_records) == 1
|
||||
|
||||
|
||||
class TestWiringForwarders:
|
||||
def test_health_snapshot_forwards_to_inner(
|
||||
self, fake_strategy: _FakeVioStrategy, store: JsonSidecarWarmStartHintStore
|
||||
) -> None:
|
||||
# Arrange
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Assert
|
||||
assert wired.health_snapshot().state == VioState.TRACKING
|
||||
|
||||
def test_current_strategy_label_forwards_to_inner(
|
||||
self, fake_strategy: _FakeVioStrategy, store: JsonSidecarWarmStartHintStore
|
||||
) -> None:
|
||||
# Arrange
|
||||
wired = _make_wired(fake_strategy, store)
|
||||
|
||||
# Assert
|
||||
assert wired.current_strategy_label() == "klt_ransac"
|
||||
|
||||
def test_wrapper_constructor_rejects_inflation_factor_le_one(
|
||||
self, fake_strategy: _FakeVioStrategy, store: JsonSidecarWarmStartHintStore
|
||||
) -> None:
|
||||
# Arrange + Act + Assert
|
||||
with pytest.raises(ValueError):
|
||||
WarmStartWiredStrategy(
|
||||
inner=fake_strategy,
|
||||
store=store,
|
||||
warm_start_max_frames=5,
|
||||
post_reset_covariance_inflation_factor=1.0,
|
||||
warm_start_save_period_frames=5,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Hint-schema sanity guard.
|
||||
|
||||
|
||||
class TestHintSchemaConstants:
|
||||
def test_hint_schema_version_is_v1(self) -> None:
|
||||
# Assert
|
||||
assert HINT_SCHEMA_VERSION == 1
|
||||
|
||||
def test_hint_filename_is_canonical(self) -> None:
|
||||
# Assert
|
||||
assert HINT_FILENAME == "c1_warm_start.json"
|
||||
|
||||
def test_warm_start_fc_source_is_runtime_checkable(self) -> None:
|
||||
# Arrange — local fake conforms to the runtime_checkable Protocol
|
||||
source = _FakeFcSource(hint=_make_hint())
|
||||
|
||||
# Assert
|
||||
assert isinstance(source, WarmStartFcSource)
|
||||
@@ -0,0 +1,457 @@
|
||||
"""AZ-528 — c1_vio facade orchestration-spine consolidation tests.
|
||||
|
||||
Covers AC-1..AC-8 of
|
||||
``_docs/02_tasks/todo/AZ-528_hygiene_c1_vio_facade_spine_consolidation.md``:
|
||||
|
||||
- AC-1: helper module exposes the documented surface.
|
||||
- AC-2: ``now_iso()`` returns an aware UTC ISO-8601 timestamp with
|
||||
``+00:00`` offset (NOT the ``Z``-suffix variant — that is
|
||||
``iso_ts_from_clock`` in AZ-526).
|
||||
- AC-3: ``bias_norm`` matches the L2 formula on a hand-checked vector.
|
||||
- AC-4: ``se3_from_4x4`` builds a ``gtsam.Pose3`` with the expected
|
||||
identity rotation + zero translation when fed ``np.eye(4)``.
|
||||
- AC-5: ``FacadeSpine._classify_state`` returns INIT during warm-up,
|
||||
TRACKING above the threshold, DEGRADED below it.
|
||||
- AC-6: ``FacadeSpine._tick_lost`` demotes TRACKING → DEGRADED on the
|
||||
first lost frame and escalates to LOST at the threshold.
|
||||
- AC-7: ``FacadeSpine._emit_transition`` emits exactly one
|
||||
``vio.health`` FDR record per state change (no record on
|
||||
steady-state).
|
||||
- AC-8: AST-walk regression guard — zero module-level definitions of
|
||||
the consolidated free functions remain in any of the three strategy
|
||||
modules. Mirrors the AZ-508 / AZ-526 / AZ-527 pattern.
|
||||
|
||||
AC-9 (existing AZ-332 / AZ-333 / AZ-334 AC tests pass unmodified) and
|
||||
AC-10 (AZ-270 layer lint) ride the existing test files; this module
|
||||
does not re-stage them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import gtsam
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.nav import FeatureQuality, ImuBias, VioState
|
||||
from gps_denied_onboard.components.c1_vio._facade_spine import (
|
||||
FacadeSpine,
|
||||
bias_norm,
|
||||
frame_image,
|
||||
frame_ts_ns,
|
||||
now_iso,
|
||||
se3_from_4x4,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.fakes import FakeFdrSink
|
||||
|
||||
_C1_VIO_SRC: Final[Path] = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "components"
|
||||
/ "c1_vio"
|
||||
)
|
||||
_STRATEGY_MODULES: Final[tuple[str, ...]] = (
|
||||
"okvis2.py",
|
||||
"vins_mono.py",
|
||||
"klt_ransac.py",
|
||||
)
|
||||
_FORBIDDEN_FREE_FUNCS: Final[frozenset[str]] = frozenset(
|
||||
{"_now_iso", "_bias_norm", "_se3_from_4x4", "_frame_ts_ns", "_frame_image"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1 — surface.
|
||||
|
||||
|
||||
def test_ac1_helper_module_exposes_documented_surface() -> None:
|
||||
# Assert
|
||||
assert callable(now_iso)
|
||||
assert callable(bias_norm)
|
||||
assert callable(se3_from_4x4)
|
||||
assert callable(frame_ts_ns)
|
||||
assert callable(frame_image)
|
||||
assert isinstance(FacadeSpine, type)
|
||||
for method_name in ("_classify_state", "_tick_lost", "_emit_transition"):
|
||||
assert callable(getattr(FacadeSpine, method_name)), method_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2 — now_iso ISO-8601 UTC with +00:00 offset.
|
||||
|
||||
|
||||
def test_ac2_now_iso_returns_aware_utc_with_plus_offset() -> None:
|
||||
# Act
|
||||
stamp = now_iso()
|
||||
|
||||
# Assert
|
||||
parsed = datetime.fromisoformat(stamp)
|
||||
assert parsed.utcoffset() is not None, "expected aware datetime"
|
||||
assert parsed.utcoffset().total_seconds() == 0.0 # type: ignore[union-attr]
|
||||
assert stamp.endswith("+00:00"), (
|
||||
f"expected '+00:00' offset suffix, not 'Z'; got {stamp!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3 — bias_norm matches the L2 formula.
|
||||
|
||||
|
||||
def test_ac3_bias_norm_matches_l2_formula() -> None:
|
||||
# Arrange
|
||||
bias = ImuBias(accel_bias=(1.0, 2.0, 2.0), gyro_bias=(0.0, 0.0, 0.0))
|
||||
|
||||
# Act
|
||||
result = bias_norm(bias)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(3.0)
|
||||
|
||||
|
||||
def test_ac3_bias_norm_includes_gyro_component() -> None:
|
||||
# Arrange
|
||||
bias = ImuBias(accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 4.0, 3.0))
|
||||
|
||||
# Act
|
||||
result = bias_norm(bias)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(5.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4 — se3_from_4x4 returns gtsam.Pose3.
|
||||
|
||||
|
||||
def test_ac4_se3_from_4x4_builds_identity_pose() -> None:
|
||||
# Act
|
||||
pose = se3_from_4x4(np.eye(4, dtype=np.float64))
|
||||
|
||||
# Assert
|
||||
assert isinstance(pose, gtsam.Pose3)
|
||||
translation = np.asarray(pose.translation())
|
||||
assert translation.shape == (3,)
|
||||
assert np.allclose(translation, np.zeros(3))
|
||||
rotation = np.asarray(pose.rotation().matrix())
|
||||
assert np.allclose(rotation, np.eye(3))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for AC-5..AC-7 — minimal test-only mixin subclass that
|
||||
# only sets the attributes the mixin reads. No native binding, no
|
||||
# config DTO, no real fdr client wiring.
|
||||
|
||||
|
||||
class _SpineHarness(FacadeSpine):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fdr: FakeFdrSink,
|
||||
producer_id: str = "c1_vio.test",
|
||||
strategy_label: str = "test_strategy",
|
||||
warm_start_max_frames: int = 5,
|
||||
feature_threshold: int = 50,
|
||||
lost_frame_threshold: int = 3,
|
||||
reported_state: VioState = VioState.INIT,
|
||||
last_emitted_state: VioState | None = None,
|
||||
frames_since_warmup: int = 0,
|
||||
consecutive_lost: int = 0,
|
||||
latest_bias: ImuBias | None = None,
|
||||
) -> None:
|
||||
self._fdr = fdr # type: ignore[assignment]
|
||||
self._producer_id = producer_id
|
||||
self._strategy_label = strategy_label
|
||||
self._warm_start_max_frames = warm_start_max_frames
|
||||
self._feature_threshold = feature_threshold
|
||||
self._lost_frame_threshold = lost_frame_threshold
|
||||
self._reported_state = reported_state
|
||||
self._last_emitted_state = last_emitted_state
|
||||
self._frames_since_warmup = frames_since_warmup
|
||||
self._consecutive_lost = consecutive_lost
|
||||
self._latest_bias = latest_bias or ImuBias(
|
||||
accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)
|
||||
)
|
||||
|
||||
|
||||
def _fq(tracked: int) -> FeatureQuality:
|
||||
return FeatureQuality(
|
||||
tracked=tracked, new=0, lost=0, mean_parallax=1.0, mre_px=0.5
|
||||
)
|
||||
|
||||
|
||||
def _fdr() -> FakeFdrSink:
|
||||
return FakeFdrSink(producer_id="c1_vio.test", capacity=64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5 — _classify_state mirrors the existing logic across all strategies.
|
||||
|
||||
|
||||
def test_ac5_classify_state_returns_init_during_warmup() -> None:
|
||||
# Arrange
|
||||
spine = _SpineHarness(
|
||||
fdr=_fdr(),
|
||||
warm_start_max_frames=5,
|
||||
feature_threshold=50,
|
||||
frames_since_warmup=0,
|
||||
reported_state=VioState.INIT,
|
||||
)
|
||||
|
||||
# Act
|
||||
state = spine._classify_state(_fq(tracked=80))
|
||||
|
||||
# Assert
|
||||
assert state == VioState.INIT
|
||||
|
||||
|
||||
def test_ac5_classify_state_returns_tracking_after_warmup() -> None:
|
||||
# Arrange
|
||||
spine = _SpineHarness(
|
||||
fdr=_fdr(),
|
||||
warm_start_max_frames=5,
|
||||
feature_threshold=50,
|
||||
frames_since_warmup=5,
|
||||
reported_state=VioState.INIT,
|
||||
)
|
||||
|
||||
# Act
|
||||
state = spine._classify_state(_fq(tracked=80))
|
||||
|
||||
# Assert
|
||||
assert state == VioState.TRACKING
|
||||
|
||||
|
||||
def test_ac5_classify_state_returns_degraded_below_threshold() -> None:
|
||||
# Arrange
|
||||
spine = _SpineHarness(
|
||||
fdr=_fdr(),
|
||||
warm_start_max_frames=5,
|
||||
feature_threshold=50,
|
||||
frames_since_warmup=10,
|
||||
reported_state=VioState.TRACKING,
|
||||
)
|
||||
|
||||
# Act
|
||||
state = spine._classify_state(_fq(tracked=10))
|
||||
|
||||
# Assert
|
||||
assert state == VioState.DEGRADED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6 — _tick_lost transitions correctly.
|
||||
|
||||
|
||||
def test_ac6_tick_lost_demotes_tracking_to_degraded_first_call() -> None:
|
||||
# Arrange
|
||||
spine = _SpineHarness(
|
||||
fdr=_fdr(),
|
||||
lost_frame_threshold=3,
|
||||
reported_state=VioState.TRACKING,
|
||||
consecutive_lost=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
spine._tick_lost("frame_42")
|
||||
|
||||
# Assert
|
||||
assert spine._reported_state == VioState.DEGRADED
|
||||
assert spine._consecutive_lost == 1
|
||||
|
||||
|
||||
def test_ac6_tick_lost_escalates_to_lost_at_threshold() -> None:
|
||||
# Arrange
|
||||
spine = _SpineHarness(
|
||||
fdr=_fdr(),
|
||||
lost_frame_threshold=3,
|
||||
reported_state=VioState.TRACKING,
|
||||
consecutive_lost=0,
|
||||
)
|
||||
|
||||
# Act
|
||||
spine._tick_lost("frame_42")
|
||||
spine._tick_lost("frame_43")
|
||||
spine._tick_lost("frame_44")
|
||||
|
||||
# Assert
|
||||
assert spine._reported_state == VioState.LOST
|
||||
assert spine._consecutive_lost == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-7 — _emit_transition emits exactly one FDR record per state change.
|
||||
|
||||
|
||||
def test_ac7_emit_transition_no_record_on_steady_state() -> None:
|
||||
# Arrange
|
||||
fdr = _fdr()
|
||||
spine = _SpineHarness(
|
||||
fdr=fdr,
|
||||
reported_state=VioState.TRACKING,
|
||||
last_emitted_state=VioState.TRACKING,
|
||||
)
|
||||
|
||||
# Act
|
||||
spine._emit_transition(VioState.TRACKING, "frame_42")
|
||||
|
||||
# Assert
|
||||
assert fdr.records == []
|
||||
assert spine._last_emitted_state == VioState.TRACKING
|
||||
|
||||
|
||||
def test_ac7_emit_transition_one_record_per_state_change() -> None:
|
||||
# Arrange
|
||||
fdr = _fdr()
|
||||
spine = _SpineHarness(
|
||||
fdr=fdr,
|
||||
producer_id="c1_vio.test",
|
||||
strategy_label="test_strategy",
|
||||
reported_state=VioState.TRACKING,
|
||||
last_emitted_state=VioState.TRACKING,
|
||||
consecutive_lost=2,
|
||||
latest_bias=ImuBias(accel_bias=(1.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0)),
|
||||
)
|
||||
|
||||
# Act
|
||||
spine._emit_transition(VioState.DEGRADED, "frame_42")
|
||||
|
||||
# Assert
|
||||
assert len(fdr.records) == 1
|
||||
record = fdr.records[0]
|
||||
assert record.kind == "vio.health"
|
||||
assert record.producer_id == "c1_vio.test"
|
||||
assert set(record.payload.keys()) == {
|
||||
"state",
|
||||
"consecutive_lost",
|
||||
"bias_norm",
|
||||
"strategy_label",
|
||||
"frame_id",
|
||||
}
|
||||
assert record.payload["state"] == VioState.DEGRADED.value
|
||||
assert record.payload["consecutive_lost"] == 2
|
||||
assert record.payload["bias_norm"] == pytest.approx(1.0)
|
||||
assert record.payload["strategy_label"] == "test_strategy"
|
||||
assert record.payload["frame_id"] == "frame_42"
|
||||
assert spine._last_emitted_state == VioState.DEGRADED
|
||||
|
||||
|
||||
def test_ac7_emit_transition_idempotent_for_repeated_state() -> None:
|
||||
# Arrange
|
||||
fdr = _fdr()
|
||||
spine = _SpineHarness(
|
||||
fdr=fdr,
|
||||
reported_state=VioState.TRACKING,
|
||||
last_emitted_state=VioState.TRACKING,
|
||||
)
|
||||
|
||||
# Act
|
||||
spine._emit_transition(VioState.DEGRADED, "frame_42")
|
||||
spine._emit_transition(VioState.DEGRADED, "frame_43")
|
||||
spine._emit_transition(VioState.DEGRADED, "frame_44")
|
||||
|
||||
# Assert
|
||||
assert len(fdr.records) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-8 — AST regression guard: no duplicated free-function definitions
|
||||
# remain in any strategy module. Mirrors the AZ-508 / AZ-526 / AZ-527
|
||||
# precedent of AST-based source-asserts so a future strategy author
|
||||
# cannot silently re-introduce a 4th local copy.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_module", _STRATEGY_MODULES)
|
||||
def test_ac8_no_duplicated_free_functions_remain_in_strategy_module(
|
||||
strategy_module: str,
|
||||
) -> None:
|
||||
# Arrange
|
||||
src = (_C1_VIO_SRC / strategy_module).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
|
||||
# Act
|
||||
offenders = sorted(
|
||||
{
|
||||
node.name
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name in _FORBIDDEN_FREE_FUNCS
|
||||
}
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert offenders == [], (
|
||||
f"{strategy_module} re-introduced consolidated free functions: "
|
||||
f"{offenders}. They live in _facade_spine.py — import them from "
|
||||
f"there instead of re-declaring."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Risk-1 mitigation — strategies set every attribute the mixin reads.
|
||||
# AST-based check: each strategy's __init__ writes every required attr
|
||||
# before any mixin method could be called externally. This is the
|
||||
# spec's "verify all required attributes set after construction"
|
||||
# pattern, executed statically so it does not require booting a fake
|
||||
# native binding for the assertion.
|
||||
|
||||
|
||||
_REQUIRED_SPINE_ATTRS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"_reported_state",
|
||||
"_frames_since_warmup",
|
||||
"_warm_start_max_frames",
|
||||
"_feature_threshold",
|
||||
"_consecutive_lost",
|
||||
"_lost_frame_threshold",
|
||||
"_last_emitted_state",
|
||||
"_producer_id",
|
||||
"_strategy_label",
|
||||
"_latest_bias",
|
||||
"_fdr",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy_module", _STRATEGY_MODULES)
|
||||
def test_strategy_init_sets_all_required_spine_attributes(
|
||||
strategy_module: str,
|
||||
) -> None:
|
||||
# Arrange
|
||||
src = (_C1_VIO_SRC / strategy_module).read_text(encoding="utf-8")
|
||||
tree = ast.parse(src)
|
||||
strategy_class = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ClassDef) and node.name.endswith("Strategy")
|
||||
)
|
||||
init_method = next(
|
||||
node
|
||||
for node in strategy_class.body
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "__init__"
|
||||
)
|
||||
|
||||
# Act
|
||||
assigned_attrs: set[str] = set()
|
||||
for stmt in ast.walk(init_method):
|
||||
if not isinstance(stmt, (ast.Assign, ast.AnnAssign)):
|
||||
continue
|
||||
targets = stmt.targets if isinstance(stmt, ast.Assign) else [stmt.target]
|
||||
for target in targets:
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and isinstance(target.value, ast.Name)
|
||||
and target.value.id == "self"
|
||||
):
|
||||
assigned_attrs.add(target.attr)
|
||||
|
||||
# Assert
|
||||
missing = _REQUIRED_SPINE_ATTRS - assigned_attrs
|
||||
assert not missing, (
|
||||
f"{strategy_module}.__init__ does not set {sorted(missing)}; "
|
||||
f"the FacadeSpine mixin needs every one before any state-machine "
|
||||
f"method runs."
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -250,13 +250,21 @@ def test_ac5_build_vio_strategy_flag_off_no_import(
|
||||
|
||||
|
||||
# Which strategies still have NO concrete Python module on disk?
|
||||
# Once an AZ-332 / AZ-333 / AZ-334 implementation lands, the
|
||||
# `flag_on_but_module_missing` semantic shifts: the factory's import
|
||||
# succeeds, the constructor fails on missing native binding or other
|
||||
# prerequisite. We assert the meaningful-error-before-first-frame
|
||||
# property holds for BOTH cases — the exception class differs by
|
||||
# strategy.
|
||||
_STRATEGIES_WITHOUT_PY_MODULE: tuple[str, ...] = ("vins_mono", "klt_ransac")
|
||||
# All three strategies (AZ-332 / AZ-333 / AZ-334) have landed; tuple
|
||||
# remains as a tombstone for git-blame archaeology of the build-time
|
||||
# gating evolution. Once removed, the parametrisation below collapses
|
||||
# to two branches (native-binding vs pure-Python).
|
||||
_STRATEGIES_WITHOUT_PY_MODULE: tuple[str, ...] = ()
|
||||
|
||||
# Strategies whose concrete implementation has NO native binding —
|
||||
# the AZ-334 KLT/RANSAC simple-baseline is pure-Python over OpenCV.
|
||||
# When ``BUILD_KLT_RANSAC=ON`` and the module is on disk, the
|
||||
# constructor succeeds end-to-end (no native ``.so`` to be missing).
|
||||
# The AC-5 spirit (meaningful error before first frame) is still
|
||||
# satisfied: the only way construction fails for klt_ransac is the
|
||||
# ``BUILD_*=OFF`` path which is already covered by
|
||||
# :func:`test_ac5_build_vio_strategy_flag_off_no_import`.
|
||||
_STRATEGIES_WITHOUT_NATIVE_BINDING: tuple[str, ...] = ("klt_ransac",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES))
|
||||
@@ -272,11 +280,22 @@ def test_ac5_build_vio_strategy_flag_on_but_module_missing(
|
||||
with pytest.raises(StrategyNotAvailableError) as exc_info:
|
||||
build_vio_strategy(config, fdr_client=object())
|
||||
assert strategy in str(exc_info.value)
|
||||
elif strategy in _STRATEGIES_WITHOUT_NATIVE_BINDING:
|
||||
# Module IS implemented AND has no native binding (AZ-334
|
||||
# KLT/RANSAC). Constructor succeeds without raising; the
|
||||
# only failure mode the AC-5 test guards against does not
|
||||
# apply to a pure-Python strategy. We assert the construction
|
||||
# produces a real :class:`VioStrategy` instance to keep the
|
||||
# test branch non-trivial.
|
||||
instance = build_vio_strategy(config, fdr_client=object())
|
||||
assert isinstance(instance, VioStrategy)
|
||||
assert instance.current_strategy_label() == strategy
|
||||
else:
|
||||
# Module IS implemented (AZ-332). Factory import succeeds, then
|
||||
# the strategy constructor fails on missing native binding —
|
||||
# which the strategy MUST surface as VioFatalError BEFORE any
|
||||
# frame is processed (the AC-5 spirit: no silent fall-through).
|
||||
# Module IS implemented (AZ-332 / AZ-333). Factory import
|
||||
# succeeds, then the strategy constructor fails on missing
|
||||
# native binding — which the strategy MUST surface as
|
||||
# VioFatalError BEFORE any frame is processed (the AC-5
|
||||
# spirit: no silent fall-through).
|
||||
with pytest.raises(VioFatalError) as exc_info:
|
||||
build_vio_strategy(config, fdr_client=object())
|
||||
assert "native binding" in str(exc_info.value)
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"""AZ-333 — :class:`VinsMonoStrategy` acceptance criteria coverage.
|
||||
|
||||
Covers AC-1 through AC-10 (with AC-9 + NFR-perf tagged
|
||||
``@pytest.mark.tier2``; the AZ-333 task spec exempts this strategy
|
||||
from the C1-PT-01 ≤ 80 ms p95 hard threshold but still asserts the
|
||||
honest-covariance monotonicity invariant on tier2 with the real
|
||||
binding).
|
||||
|
||||
Uses the ``fake_vins_mono_binding`` fixture from ``conftest.py`` to
|
||||
script backend responses — the task spec explicitly permits a fake
|
||||
binding for backend-exception injection (AC-3 / AC-6 / AC-7) and by
|
||||
extension the rest of the Python-facade-only AC suite.
|
||||
|
||||
Mirrors the AZ-332 ``test_okvis2_strategy.py`` layout deliberately:
|
||||
the AZ-331 factory produces both via the same `(config, *,
|
||||
fdr_client)` shape and the IT-12 comparative-study harness expects the
|
||||
two to behave identically through the Python facade.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import gtsam
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard._types.nav import (
|
||||
ImuBias,
|
||||
ImuSample,
|
||||
ImuWindow,
|
||||
NavCameraFrame,
|
||||
VioOutput,
|
||||
VioState,
|
||||
WarmStartPose,
|
||||
)
|
||||
from gps_denied_onboard.components.c1_vio import (
|
||||
C1VioConfig,
|
||||
VinsMonoConfig,
|
||||
VioError,
|
||||
VioFatalError,
|
||||
VioInitializingError,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import Config, RuntimeConfig
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from tests.unit.c1_vio.conftest import (
|
||||
FakeVinsMonoBackend,
|
||||
FakeVinsMonoFatalException,
|
||||
FakeVinsMonoInitException,
|
||||
FakeVinsMonoOptimizationException,
|
||||
ScriptedOutput,
|
||||
)
|
||||
|
||||
|
||||
def _zero_bias() -> ImuBias:
|
||||
return ImuBias(accel_bias=(0.0, 0.0, 0.0), gyro_bias=(0.0, 0.0, 0.0))
|
||||
|
||||
|
||||
def _calibration() -> CameraCalibration:
|
||||
return CameraCalibration(
|
||||
camera_id="test-cam",
|
||||
intrinsics_3x3=np.eye(3, dtype=np.float64),
|
||||
distortion=np.zeros(4, dtype=np.float64),
|
||||
body_to_camera_se3=np.eye(4, dtype=np.float64),
|
||||
acquisition_method="unit-test-static",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def _frame(idx: int = 1, ts_ns: int = 1_000_000_000) -> NavCameraFrame:
|
||||
return NavCameraFrame(
|
||||
frame_id=idx,
|
||||
timestamp=datetime.fromtimestamp(ts_ns * 1e-9, tz=timezone.utc),
|
||||
image=np.zeros((4, 4, 3), dtype=np.uint8),
|
||||
camera_calibration_id="test-cam",
|
||||
)
|
||||
|
||||
|
||||
def _imu_window(ts_ns_start: int = 999_000_000, n: int = 3) -> ImuWindow:
|
||||
samples = tuple(
|
||||
ImuSample(
|
||||
ts_ns=ts_ns_start + i * 5_000_000,
|
||||
accel_xyz=(0.0, 0.0, 9.81),
|
||||
gyro_xyz=(0.0, 0.0, 0.0),
|
||||
)
|
||||
for i in range(n)
|
||||
)
|
||||
return ImuWindow(
|
||||
samples=samples,
|
||||
ts_start_ns=samples[0].ts_ns,
|
||||
ts_end_ns=samples[-1].ts_ns,
|
||||
)
|
||||
|
||||
|
||||
def _warm_start_hint() -> WarmStartPose:
|
||||
return WarmStartPose(
|
||||
body_T_world=gtsam.Pose3(np.eye(4)),
|
||||
velocity_b=(0.5, 0.0, 0.0),
|
||||
bias=ImuBias(
|
||||
accel_bias=(0.01, -0.02, 0.0),
|
||||
gyro_bias=(0.003, 0.0, -0.001),
|
||||
),
|
||||
captured_at_ns=1_000_000_000,
|
||||
)
|
||||
|
||||
|
||||
def _config(
|
||||
vins_cfg: VinsMonoConfig | None = None,
|
||||
lost_frame_threshold: int = 9,
|
||||
warm_start_max_frames: int = 5,
|
||||
) -> Config:
|
||||
return Config.with_blocks(
|
||||
c1_vio=C1VioConfig(
|
||||
strategy="vins_mono",
|
||||
lost_frame_threshold=lost_frame_threshold,
|
||||
warm_start_max_frames=warm_start_max_frames,
|
||||
vins_mono=vins_cfg or VinsMonoConfig(),
|
||||
),
|
||||
runtime=RuntimeConfig(camera_calibration_path=""),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fdr_client() -> FdrClient:
|
||||
return FdrClient(producer_id="c1_vio.vins_mono", capacity=256, _emit_diag_log=False)
|
||||
|
||||
|
||||
def _build_strategy(
|
||||
fdr_client: FdrClient,
|
||||
config: Config | None = None,
|
||||
):
|
||||
"""Lazy import after the fake binding is installed in sys.modules."""
|
||||
from gps_denied_onboard.components.c1_vio.vins_mono import VinsMonoStrategy
|
||||
|
||||
return VinsMonoStrategy(config or _config(), fdr_client=fdr_client)
|
||||
|
||||
|
||||
def _drain(fdr_client: FdrClient) -> list[FdrRecord]:
|
||||
return fdr_client.drain(max_records=1024)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-1: current_strategy_label returns "vins_mono".
|
||||
|
||||
|
||||
def test_ac1_current_strategy_label_returns_vins_mono(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
strategy = _build_strategy(fdr_client)
|
||||
assert strategy.current_strategy_label() == "vins_mono"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-2: process_frame returns VioOutput with echoed frame_id, SPD cov, bias.
|
||||
|
||||
|
||||
def test_ac2_process_frame_returns_vio_output_with_frame_id(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
backend.script(ScriptedOutput(produced=True))
|
||||
|
||||
out = strategy.process_frame(_frame(idx=42), _imu_window(), _calibration())
|
||||
|
||||
assert isinstance(out, VioOutput)
|
||||
assert out.frame_id == "42"
|
||||
assert out.pose_covariance_6x6.shape == (6, 6)
|
||||
assert np.allclose(out.pose_covariance_6x6, out.pose_covariance_6x6.T)
|
||||
eigvals = np.linalg.eigvalsh(out.pose_covariance_6x6)
|
||||
assert np.all(eigvals > 0), "covariance must be SPD"
|
||||
assert out.imu_bias is not None
|
||||
assert out.feature_quality.tracked > 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-3: backend exceptions rewrap into VioError with __cause__ chain.
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fake_exc_cls, expected_facade_exc",
|
||||
[
|
||||
(FakeVinsMonoInitException, VioInitializingError),
|
||||
(FakeVinsMonoFatalException, VioFatalError),
|
||||
],
|
||||
)
|
||||
def test_ac3_backend_exceptions_rewrap_to_vio_error_family(
|
||||
fake_vins_mono_binding, fdr_client, fake_exc_cls, expected_facade_exc
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
backend.script(ScriptedOutput(raise_with=fake_exc_cls("boom from backend")))
|
||||
|
||||
with pytest.raises(expected_facade_exc) as exc_info:
|
||||
strategy.process_frame(_frame(), _imu_window(), _calibration())
|
||||
|
||||
assert isinstance(exc_info.value, VioError)
|
||||
assert isinstance(exc_info.value.__cause__, fake_exc_cls)
|
||||
|
||||
|
||||
def test_ac3_optimization_exception_during_init_rewraps_to_initializing(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=5, lost_frame_threshold=9)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
backend.script(
|
||||
ScriptedOutput(raise_with=FakeVinsMonoOptimizationException("opt fail"))
|
||||
)
|
||||
|
||||
with pytest.raises(VioInitializingError) as exc_info:
|
||||
strategy.process_frame(_frame(), _imu_window(), _calibration())
|
||||
|
||||
assert isinstance(exc_info.value.__cause__, FakeVinsMonoOptimizationException)
|
||||
|
||||
|
||||
def test_ac3_unmapped_runtime_error_rewraps_to_vio_fatal(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
backend.script(ScriptedOutput(raise_with=RuntimeError("library leaked this")))
|
||||
|
||||
with pytest.raises(VioFatalError) as exc_info:
|
||||
strategy.process_frame(_frame(), _imu_window(), _calibration())
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-4: reset_to_warm_start clears state and seeds the hint; idempotent.
|
||||
|
||||
|
||||
def test_ac4_reset_to_warm_start_clears_and_seeds(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
strategy = _build_strategy(fdr_client)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
hint = _warm_start_hint()
|
||||
strategy.reset_to_warm_start(hint)
|
||||
|
||||
assert backend.reset_call_count == 1
|
||||
health = strategy.health_snapshot()
|
||||
assert health.state == VioState.INIT
|
||||
assert health.consecutive_lost == 0
|
||||
# bias_norm > 0 because the hint carries a non-zero bias
|
||||
assert health.bias_norm > 0.0
|
||||
|
||||
|
||||
def test_ac4_reset_to_warm_start_is_idempotent(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
strategy = _build_strategy(fdr_client)
|
||||
hint = _warm_start_hint()
|
||||
strategy.reset_to_warm_start(hint)
|
||||
strategy.reset_to_warm_start(hint)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
assert backend.reset_call_count == 2
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-5: INIT initially -> TRACKING after warm_start_max_frames frames.
|
||||
|
||||
|
||||
def test_ac5_health_snapshot_init_then_tracking(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=3)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
assert strategy.health_snapshot().state == VioState.INIT
|
||||
|
||||
backend.script(
|
||||
ScriptedOutput(produced=True),
|
||||
ScriptedOutput(produced=True),
|
||||
ScriptedOutput(produced=True),
|
||||
)
|
||||
for i in range(3):
|
||||
strategy.process_frame(
|
||||
_frame(idx=i + 1, ts_ns=1_000_000_000 + i * 1_000_000),
|
||||
_imu_window(ts_ns_start=999_000_000 + i * 100_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
|
||||
assert strategy.health_snapshot().state == VioState.TRACKING
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-6: DEGRADED on feature loss; VioOutput STILL emitted (not raised);
|
||||
# covariance Frobenius norm strictly increases on the degraded frame.
|
||||
|
||||
|
||||
def test_ac6_degraded_on_feature_loss_emits_vio_output(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
healthy_payload = {
|
||||
"tracked_features": 80,
|
||||
"pose_covariance_6x6": np.eye(6, dtype=np.float64) * 0.01,
|
||||
}
|
||||
degraded_payload = {
|
||||
"tracked_features": 5,
|
||||
"pose_covariance_6x6": np.eye(6, dtype=np.float64) * 0.5,
|
||||
}
|
||||
backend.script(
|
||||
ScriptedOutput(produced=True, payload=healthy_payload),
|
||||
ScriptedOutput(produced=True, payload=degraded_payload),
|
||||
)
|
||||
|
||||
healthy_out = strategy.process_frame(_frame(idx=1), _imu_window(), _calibration())
|
||||
degraded_out = strategy.process_frame(
|
||||
_frame(idx=2, ts_ns=1_100_000_000),
|
||||
_imu_window(ts_ns_start=1_099_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
|
||||
assert isinstance(degraded_out, VioOutput), "DEGRADED frame MUST emit output"
|
||||
assert strategy.health_snapshot().state == VioState.DEGRADED
|
||||
healthy_norm = np.linalg.norm(healthy_out.pose_covariance_6x6, ord="fro")
|
||||
degraded_norm = np.linalg.norm(degraded_out.pose_covariance_6x6, ord="fro")
|
||||
assert degraded_norm > healthy_norm, (
|
||||
f"Frobenius norm must increase on DEGRADED frame "
|
||||
f"(healthy={healthy_norm}, degraded={degraded_norm})"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-7: After lost_frame_threshold consecutive failures, raise VioFatalError;
|
||||
# state == LOST.
|
||||
|
||||
|
||||
def test_ac7_sustained_loss_raises_vio_fatal_error(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(lost_frame_threshold=3, warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
backend.script(
|
||||
ScriptedOutput(raise_with=FakeVinsMonoOptimizationException("loss-1")),
|
||||
ScriptedOutput(raise_with=FakeVinsMonoOptimizationException("loss-2")),
|
||||
ScriptedOutput(raise_with=FakeVinsMonoOptimizationException("loss-3")),
|
||||
)
|
||||
|
||||
with pytest.raises(VioInitializingError):
|
||||
strategy.process_frame(_frame(idx=1), _imu_window(), _calibration())
|
||||
with pytest.raises(VioInitializingError):
|
||||
strategy.process_frame(
|
||||
_frame(idx=2, ts_ns=1_100_000_000),
|
||||
_imu_window(ts_ns_start=1_099_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
with pytest.raises(VioFatalError):
|
||||
strategy.process_frame(
|
||||
_frame(idx=3, ts_ns=1_200_000_000),
|
||||
_imu_window(ts_ns_start=1_199_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
|
||||
assert strategy.health_snapshot().state == VioState.LOST
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-8: BUILD_VINS_MONO=OFF lazy-import guarantee — complementary check.
|
||||
# (Primary AC-8 coverage lives in test_protocol_conformance.py via the
|
||||
# AZ-331 factory which gates BEFORE constructor.)
|
||||
|
||||
|
||||
def test_ac8_strategy_module_not_imported_at_package_load(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Importing `c1_vio` itself MUST NOT load `c1_vio.vins_mono`.
|
||||
|
||||
Risk-2 / Risk-3 guard — the factory respects the BUILD_VINS_MONO
|
||||
flag and only triggers the import on demand. This complements the
|
||||
test_ac5_build_vio_strategy_flag_off_no_import test in
|
||||
test_protocol_conformance.py.
|
||||
"""
|
||||
import sys
|
||||
|
||||
sys.modules.pop("gps_denied_onboard.components.c1_vio.vins_mono", None)
|
||||
sys.modules.pop("gps_denied_onboard.components.c1_vio", None)
|
||||
|
||||
import importlib
|
||||
|
||||
importlib.import_module("gps_denied_onboard.components.c1_vio")
|
||||
|
||||
assert "gps_denied_onboard.components.c1_vio.vins_mono" not in sys.modules
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-9: tier2 — honest covariance Frobenius monotonically non-decreasing
|
||||
# across a controlled-degradation window.
|
||||
|
||||
|
||||
@pytest.mark.tier2
|
||||
def test_ac9_honest_covariance_monotonic_during_degraded(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
"""Tier-2: 60 s controlled-degradation fixture; covariance MUST not
|
||||
shrink during the DEGRADED window.
|
||||
|
||||
The fake binding here exercises the facade's enforcement contract —
|
||||
real validation against VINS-Mono's marginalised information matrix
|
||||
is the Jetson-side follow-up that wires
|
||||
:class:`vins_estimator::Estimator` (skeleton today).
|
||||
"""
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
base_cov = np.eye(6, dtype=np.float64) * 0.01
|
||||
backend.script(
|
||||
ScriptedOutput(produced=True, payload={"tracked_features": 80}),
|
||||
*[
|
||||
ScriptedOutput(
|
||||
produced=True,
|
||||
payload={
|
||||
"tracked_features": 10,
|
||||
"pose_covariance_6x6": base_cov * (1.0 + i),
|
||||
},
|
||||
)
|
||||
for i in range(5)
|
||||
],
|
||||
)
|
||||
|
||||
outputs = []
|
||||
for i in range(6):
|
||||
outputs.append(
|
||||
strategy.process_frame(
|
||||
_frame(idx=i + 1, ts_ns=1_000_000_000 + i * 1_000_000),
|
||||
_imu_window(ts_ns_start=999_000_000 + i * 100_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
)
|
||||
|
||||
import itertools
|
||||
|
||||
degraded_outputs = outputs[1:] # 5 DEGRADED frames
|
||||
norms = [np.linalg.norm(o.pose_covariance_6x6, ord="fro") for o in degraded_outputs]
|
||||
for prev, curr in itertools.pairwise(norms):
|
||||
assert curr >= prev, (
|
||||
f"covariance Frobenius norm must be monotonically non-decreasing "
|
||||
f"during DEGRADED; got prev={prev}, curr={curr}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# AC-10: Exactly one vio.health record per state transition; no spam on
|
||||
# steady-state.
|
||||
|
||||
|
||||
def test_ac10_fdr_vio_health_emitted_per_transition(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
pre_records = _drain(fdr_client)
|
||||
assert pre_records == [], "construction must not emit vio.health"
|
||||
|
||||
backend.script(
|
||||
ScriptedOutput(produced=True, payload={"tracked_features": 80}),
|
||||
ScriptedOutput(produced=True, payload={"tracked_features": 80}),
|
||||
ScriptedOutput(produced=True, payload={"tracked_features": 10}),
|
||||
ScriptedOutput(produced=True, payload={"tracked_features": 80}),
|
||||
)
|
||||
|
||||
for i in range(4):
|
||||
strategy.process_frame(
|
||||
_frame(idx=i + 1, ts_ns=1_000_000_000 + i * 1_000_000),
|
||||
_imu_window(ts_ns_start=999_000_000 + i * 100_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
|
||||
records = _drain(fdr_client)
|
||||
assert all(r.kind == "vio.health" for r in records)
|
||||
states = [r.payload["state"] for r in records]
|
||||
assert states == ["tracking", "degraded", "tracking"], (
|
||||
f"unexpected transition sequence: {states}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# NFR-perf-document (tier2): VINS-Mono p95 is *recorded*, not bounded.
|
||||
# Per AZ-333 task spec NFR-perf, no hard threshold — Step 9 / E-BBT
|
||||
# comparative report consumes the p50/p95 numbers.
|
||||
|
||||
|
||||
@pytest.mark.tier2
|
||||
def test_nfr_perf_process_frame_records_p95(fake_vins_mono_binding, fdr_client) -> None:
|
||||
"""Tier-2: Real VINS-Mono binding + Derkachi-class fixture.
|
||||
|
||||
Unlike :class:`Okvis2Strategy`, VINS-Mono is research-only and not
|
||||
bound by C1-PT-01's ≤ 80 ms p95. We record p95 here and assert
|
||||
only that it can be measured (i.e. process_frame completes 200x
|
||||
without deadlock or unbounded growth). The Step 9 / E-BBT
|
||||
comparative-study report ingests the produced number.
|
||||
"""
|
||||
import time
|
||||
|
||||
config = _config(warm_start_max_frames=1)
|
||||
strategy = _build_strategy(fdr_client, config)
|
||||
backend: FakeVinsMonoBackend = strategy._backend # type: ignore[attr-defined]
|
||||
|
||||
n = 200
|
||||
backend.script(*[ScriptedOutput(produced=True) for _ in range(n)])
|
||||
|
||||
durations_ms: list[float] = []
|
||||
for i in range(n):
|
||||
t0 = time.perf_counter()
|
||||
strategy.process_frame(
|
||||
_frame(idx=i + 1, ts_ns=1_000_000_000 + i * 1_000_000),
|
||||
_imu_window(ts_ns_start=999_000_000 + i * 100_000_000),
|
||||
_calibration(),
|
||||
)
|
||||
durations_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||
|
||||
durations_ms.sort()
|
||||
p95 = durations_ms[int(0.95 * len(durations_ms))]
|
||||
assert p95 >= 0.0, f"VinsMono p95 must be measurable (got {p95})"
|
||||
# Loose sanity ceiling so a regression to seconds-per-frame fails the
|
||||
# tier2 run; VINS-Mono is best-effort but not pathologically slow.
|
||||
assert p95 <= 5_000.0, (
|
||||
f"VinsMono process_frame p95={p95:.3f} ms grew pathologically "
|
||||
"(>5 s); investigate before publishing comparative report"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Construction guards.
|
||||
|
||||
|
||||
def test_construct_with_wrong_strategy_label_raises(
|
||||
fake_vins_mono_binding, fdr_client
|
||||
) -> None:
|
||||
"""Constructing directly with a non-vins_mono strategy is a developer bug."""
|
||||
bad_config = Config.with_blocks(c1_vio=C1VioConfig(strategy="klt_ransac"))
|
||||
from gps_denied_onboard.components.c1_vio.vins_mono import VinsMonoStrategy
|
||||
|
||||
with pytest.raises(VioFatalError):
|
||||
VinsMonoStrategy(bad_config, fdr_client=fdr_client)
|
||||
|
||||
|
||||
def test_build_via_factory_returns_vins_mono_strategy(
|
||||
fake_vins_mono_binding, fdr_client, monkeypatch
|
||||
) -> None:
|
||||
"""End-to-end factory wiring smoke — exercises the BUILD flag gate +
|
||||
lazy import path the conformance tests don't touch for the real
|
||||
`VinsMonoStrategy` class.
|
||||
"""
|
||||
monkeypatch.setenv("BUILD_VINS_MONO", "ON")
|
||||
from gps_denied_onboard.components.c1_vio.vins_mono import VinsMonoStrategy
|
||||
from gps_denied_onboard.runtime_root.vio_factory import build_vio_strategy
|
||||
|
||||
instance = build_vio_strategy(_config(), fdr_client=fdr_client)
|
||||
assert isinstance(instance, VinsMonoStrategy)
|
||||
assert instance.current_strategy_label() == "vins_mono"
|
||||
@@ -131,6 +131,14 @@ def _kind_payload(kind: str) -> dict[str, object]:
|
||||
"strategy_label": "okvis2",
|
||||
"frame_id": "frame-0001",
|
||||
}
|
||||
if kind == "vio.warm_start":
|
||||
return {
|
||||
"source": "f8_reboot_disk",
|
||||
"strategy_label": "klt_ransac",
|
||||
"bias_norm": 0.0345,
|
||||
"staleness_ns": 12_345_678,
|
||||
"pre_reboot_covariance_norm": 0.0625,
|
||||
}
|
||||
if kind == "c7.thermal_transition":
|
||||
return {
|
||||
"previous_state": False,
|
||||
|
||||
Reference in New Issue
Block a user