mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 17:31:14 +00:00
b3ad94c155
Replaces the last three NotImplementedError placeholders on GtsamIsam2StateEstimator with real Marginals + output methods: - current_estimate(): recovers the 6x6 Marginals covariance for the most-recently committed pose key, enforces the SPD invariant via np.linalg.cholesky (Invariant 10), converts the local-ENU pose translation to WGS84 via the shared WgsConverter, derives a body->world quaternion, and emits a fresh EstimatorOutput (smoothed=False, Invariant 4). On SPD failure transitions isam2_state -> LOST and raises EstimatorFatalError (AC-5.2 path). - smoothed_history(n): iterates the smoother's active POSE keys via _smoother.calculateEstimate().keys() (filtered by GTSAM symbol char) and the smoother timestamps via ts_map.at(key) - workaround for the pinned gtsam_unstable build's non-iterable FixedLagSmootherKeyTimestampMap. Bounded by K (Invariant 6); every entry has smoothed=True (Invariant 7). - health_snapshot(): cheap O(1) accumulator read; reports IsamState lifecycle, pose-key count, AC-NEW-8 cov_norm_growing_for_s rolling 60s deque-backed counter, and spoof_promotion_blocked via the AZ-385 state machine injection point. Adds two public injection points for AZ-385/composition root: set_enu_origin(LatLonAlt) and attach_source_label_state_machine(machine). Defaults: (0, 0, 0) ENU origin, VISUAL_PROPAGATED source label, spoof_promotion_blocked=False. Wires _record_committed_pose_key into the three add_* success paths so current_estimate only reads keys that have real values in iSAM2. The JACOBIAN path in add_pose_anchor deliberately skips this call - Invariant 3 keeps the JACOBIAN pose out of the iSAM2 graph. Tests: +27 in tests/unit/c5_state/test_az384_marginals_outputs.py covering all 10 ACs. Three obsolete AZ-382 tests (test_ac10_*_raises_named_az384) removed. Full suite: 589 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
10 KiB
10 KiB
Batch 15 — Cycle 1 Implementation Report
Batch: 15 of N
Tasks landed: AZ-384 (GtsamIsam2StateEstimator — Marginals + output methods)
Cycle: 1
Date: 2026-05-11
Scope
| Task | Component | Purpose |
|---|---|---|
| AZ-384 | C5 state estimator | Replaces the three remaining NotImplementedError placeholders on GtsamIsam2StateEstimator (current_estimate, smoothed_history, health_snapshot) with real implementations. current_estimate recovers the 6x6 Marginals covariance for the most-recently committed pose key, enforces the SPD invariant via Cholesky decomposition (Invariant 10), converts the local-ENU pose translation to WGS84 via the shared WgsConverter, derives a body→world quaternion from the iSAM2 pose, and emits a fresh EstimatorOutput(smoothed=False) (Invariant 4). smoothed_history(n) iterates the smoother's active POSE keys (filtered by GTSAM symbol char), sorts by per-key timestamp from IncrementalFixedLagSmoother.timestamps(), takes the most recent min(n, K) entries, and emits EstimatorOutput(smoothed=True) for each (Invariant 6 + 7). health_snapshot returns an O(1) accumulator read — IsamState lifecycle, pose-key count from the smoother estimate, AC-NEW-8 cov_norm_growing_for_s rolling-window counter, and the spoof-promotion gate query through the (still-pending AZ-385) state machine injection point. |
Files added / modified
Modified (prod)
src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py— replaced the three ProtocolNotImplementedErrorbodies with real Marginals + output method bodies; introduced six new state fields onGtsamIsam2StateEstimator(_last_committed_pose_key,_enu_origin,_source_label_machine,_cov_norm_window,_isam2_state); added two public injection methods (set_enu_origin,attach_source_label_state_machine); added seven internal helpers (_record_committed_pose_key,_pose_at_key,_enu_pose_to_wgs84,_latest_velocity_or_zero,_derive_source_label,_spoof_promotion_blocked,_smoother_keyframe_count,_record_cov_norm_sample,_prune_cov_norm_window,_cov_norm_growing_for_s); wired_record_committed_pose_keyinto the threeadd_*success paths (sets the key the nextcurrent_estimatewill read); added two module-level pure helpers (_quat_from_pose3,_enforce_spd); added module-level constants_COV_NORM_WINDOW_NS = 60 * 1e9ns and_DEFAULT_ENU_ORIGIN = LatLonAlt(0, 0, 0).
Added (tests)
tests/unit/c5_state/test_az384_marginals_outputs.py— 27 tests covering all 10 ACs. The shared_seed_priorhelper plants a real prior factor + initial value + timestamp map onto the iSAM2 + smoother — the minimal scaffoldingcurrent_estimateneeds (AZ-388 will own this seeding at startup once the AC-5.2 fallback lands).
Modified (tests)
tests/unit/c5_state/test_az382_isam2_smoother_wiring.py— removed the three now-obsoletetest_ac10_*_raises_named_az384tests (current_estimate/smoothed_history/health_snapshotno longer raiseNotImplementedError; they have real bodies). Replaced with a comment block pointing readers to bothtest_az383_factor_adds.pyandtest_az384_marginals_outputs.py.
Architectural notes
_last_committed_pose_keytracking — set ONLY after a successfulhandle.updatethat committed a value for the key. The JACOBIAN path inadd_pose_anchordeliberately does NOT call_record_committed_pose_keybecause Invariant 3 forbids adding the JACOBIAN pose to the iSAM2 graph (the running estimate consumes it downstream, but the graph stops growing under throttle). This meanscurrent_estimatereads only keys that have ACTUAL values in iSAM2 — no missing-key surprises in the steady state.- ENU origin as injection point —
set_enu_origin(LatLonAlt)is the wiring seam for AZ-385 (which will derive the origin from the first satellite-anchored pose via the spoof-promotion gate). Before the wire-up, the estimator falls back to_DEFAULT_ENU_ORIGIN = (0, 0, 0)— correct for tests, obviously not for flight; the composition root SHOULD callset_enu_originbefore steady-state operation. - GTSAM iteration via
calculateEstimate().keys()+timestamps().at(key)—FixedLagSmootherKeyTimestampMapis not iterable in the pinnedgtsam_unstablebuild (noitems(), nokeys(), no__iter__). The workaround: iterate_smoother.calculateEstimate().keys()(which returns the smoother's active values, bounded by the K-keyframe window), filter to'x'-namespace pose keys viagtsam.symbolChr(key) == ord("x"), then probe per-key timestamps withts_map.at(int(key)). Documented inline as a forward-compat note since a future GTSAM version may exposeitems()directly. - Smoother estimate vs. iSAM2 estimate —
current_estimatereads from_isam2.calculateEstimate()(the filter posterior — the most up-to-date best estimate).smoothed_historyreads from_smoother.calculateEstimate()(the smoother posterior — past keyframes re-optimised given all later evidence). This split matches the C5 contract semantics:smoothed=Trueentries are genuinely smoothed;smoothed=Falseentries are filter-only. - SPD invariant enforcement (Invariant 10) —
_enforce_spdusesnp.linalg.cholesky(same primitive iSAM2 uses internally). OnLinAlgErrorwe transition_isam2_state = LOST, log a structuredc5.state.current_estimate_spd_failedrecord with the covariance Frobenius norm for forensics, and raiseEstimatorFatalError— which triggers the AC-5.2 IMU-only fallback path in C8 once AZ-388 lands. - AC-NEW-8
cov_norm_growing_for_srolling window — implemented as adeque[(monotonic_ns, fro_norm)]lazy-pruned to a 60 s window on every_record_cov_norm_samplecall. The "growing for" length is computed by walking the deque newest → oldest until the chain breaks (a sample whose norm is ≤ its successor). Cheap O(n) per snapshot, n bounded by the keyframe rate × 60 s. - Source-label state machine — injection point only —
attach_source_label_state_machineaccepts any object exposingcurrent_label() -> PoseSourceLabelandis_spoof_promotion_blocked() -> bool. AZ-385 owns the actual state-machine impl + transition logic. Defaults:VISUAL_PROPAGATEDlabel andspoof_promotion_blocked = False. Exceptions raised by the injected machine are logged but downgraded to the defaults so a flaky state machine does NOT take down the estimator. - No marginals call in
health_snapshot— the contract NFR ishealth_snapshotp99 ≤ 5 µs. Strictly accumulator reads + a singlecalculateEstimate().keys()walk for the pose-key count. Verified by an explicit "no compute_marginals call" test.
Test counts
| Suite | Before (B14) | After (B15) | Delta |
|---|---|---|---|
| Total passing | 565 | 589 | +24 |
| Skipped | 2 | 2 | 0 |
| AZ-384 (new) | 0 | 27 | +27 |
| AZ-382 (preserved) | 24 | 21 | −3 (obsolete test_ac10_*_raises_named_az384 tests removed; behaviour now lives in AZ-384 tests) |
Run command: PYTHONPATH=src pytest tests/ -q → 589 passed, 2 skipped in ~19s.
Lint / type
ruff check src/gps_denied_onboard/components/c5_state/ tests/unit/c5_state/— clean.ruff format— one file reformatted, 12 already formatted.ReadLintson touched files — 0 errors.
Acceptance evidence
| AC | Test(s) | Status |
|---|---|---|
AC-1 Fresh EstimatorOutput |
test_ac1_current_estimate_returns_fresh_estimator_output, test_ac1_no_committed_pose_key_raises_fatal |
PASS |
| AC-2 SPD covariance | test_ac2_spd_invariant_holds_for_real_marginals, test_ac2_non_spd_marginals_raises_fatal |
PASS |
| AC-3 WGS84 conversion | test_ac3_default_origin_is_equator, test_ac3_explicit_origin_round_trips, test_ac3_translated_pose_offsets_from_origin |
PASS |
AC-4 smoothed_history bounded by K |
test_ac4_smoothed_history_bounded_by_k, test_ac4_smoothed_history_entries_have_smoothed_true, test_ac4_smoothed_history_empty_when_n_zero, test_ac4_smoothed_history_empty_before_seed |
PASS |
AC-5 current_estimate has smoothed=False |
test_ac5_current_estimate_smoothed_false |
PASS |
AC-6 isam2_state lifecycle |
test_ac6_isam2_state_init_before_first_estimate, test_ac6_isam2_state_tracking_after_estimate, test_ac6_isam2_state_lost_after_fatal |
PASS |
AC-7 keyframe_count accuracy |
test_ac7_keyframe_count_initially_zero, test_ac7_keyframe_count_grows_with_seeded_keys |
PASS |
AC-8 cov_norm_growing_for_s |
test_ac8_cov_norm_growing_zero_with_constant_norm, test_ac8_cov_norm_growing_increments_under_rising_norm, test_ac8_cov_norm_growing_resets_on_drop |
PASS |
AC-9 spoof_promotion_blocked via state machine |
test_ac9_default_spoof_promotion_blocked_false, test_ac9_spoof_promotion_blocked_from_state_machine, test_ac9_state_machine_drives_source_label, test_ac9_default_source_label_is_visual_propagated |
PASS |
AC-10 last_satellite_anchor_age_ms pass-through |
test_ac10_last_satellite_anchor_age_ms_passthrough, test_ac10_emitted_at_is_monotonic_ns |
PASS |
Defensive — cheap health_snapshot |
test_health_snapshot_does_not_call_marginals |
PASS |
Known forward actions (not in scope this batch)
- AZ-385 (source-label + spoof-promotion gate) — owns the
SourceLabelStateMachineimplementation that AZ-384 only holds a reference to. Will also wireset_enu_originto the first satellite-anchored pose. - AZ-386 (ESKF baseline) — the mandatory simple-baseline
EskfStateEstimator(IT-12 engine rule). Independent of AZ-384; shares the sameEstimatorOutput/EstimatorHealthDTOs. - AZ-387 (smoothed history → FDR) — the C13 FDR writer path. AZ-384's
smoothed_history(n)is the input. - AZ-388 (AC-5.2 fallback) — owns the startup
add_vio-first-frame seeding so the full real-iSAM2 ↔ AZ-383 ↔ AZ-384 chain runs end-to-end. The AZ-384 test fixture's_seed_priorhelper mirrors what AZ-388 will do at composition-root startup. _latest_velocity_or_zero— currently returns zeros until an IMU keyframe has committed a velocity value viaadd_fc_imu. The actual nonzero-velocity case will exercise once AZ-388 lands first-frame seeding for the IMU chain.