mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 17:21:12 +00:00
c0bdb57957
Implements the mandatory simple-baseline StateEstimator per AC-2.1a engine-rule at C5 (IT-12 comparative study vs iSAM2). NumPy-only; no GTSAM dependency so BUILD_STATE_ESKF=ON binaries ship without GTSAM at all. - 16-state error vector (pos 3 + vel 3 + rot 3 + ba 3 + bg 3 + dt 1) over a textbook nominal-state / error-state ESKF split. - add_fc_imu: full nonlinear IMU integration + linearised F P F^T + Q covariance propagation per IMU sample. - add_vio: simplified relative-pose update (snapshot-based; baseline scope, documented). - add_pose_anchor: absolute-pose update; integrates BOTH marginals and jacobian modes (no skip — ESKF has no graph; AC-4). - AC-9 divergence test: Mahalanobis r^T S^-1 r > 100 (10 sigma) on the innovation covariance S = H P H^T + R. - AC-5 SPD: Cholesky-positive enforcement on every emitted covariance; non-SPD raises EstimatorFatalError and locks state to LOST. - AC-6 honesty: smoothed_history entries carry smoothed=False; deviation from C5 contract Invariant 7 documented in module + report. - AC-7 / AC-10 BUILD_STATE_ESKF gating: works through existing factory infra (state_factory._STATE_BUILD_FLAGS). - AC-8: SourceLabelStateMachine + FallbackWatcher auto-wired eagerly in __init__, same pattern as the iSAM2 estimator. Tests: 20 new unit tests covering AC-1..AC-10 + robustness checks. Full suite: 660 passed, 2 skipped (CI-only). The AZ-386 Jira transition to Done is deferred (Atlassian MCP returned 'Not connected'); recorded in _docs/_process_leftovers/ for replay on the next autodev invocation per the Leftovers Mechanism. Co-authored-by: Cursor <cursoragent@cursor.com>
9.3 KiB
9.3 KiB
Batch 19 — Cycle 1 Implementation Report
Batch: 19 of N
Tasks landed: AZ-386 (EskfStateEstimator — mandatory simple-baseline)
Cycle: 1
Date: 2026-05-11
Scope
| Task | Component | Purpose |
|---|---|---|
| AZ-386 | C5 state estimator | Implements the mandatory simple-baseline StateEstimator per AC-2.1a engine-rule applied at the state-estimator level (IT-12 comparative study against the production iSAM2 estimator). 16-state error-state Kalman filter (position 3 + velocity 3 + orientation 3 + accel-bias 3 + gyro-bias 3 + IMU dt offset 1). NumPy-only; no GTSAM dependency in this module — BUILD_STATE_ESKF=ON binaries can ship without GTSAM at all. Wires the existing AZ-385 SourceLabelStateMachine and AZ-388 FallbackWatcher infrastructure via the same construction-time eager-construction pattern the iSAM2 estimator uses, so the source-label gate + AC-5.2 fallback semantics are identical across both estimator strategies. |
Files added / modified
Added (prod)
src/gps_denied_onboard/components/c5_state/eskf_baseline.py(~680 LoC) — definesEskfStateEstimatorimplementing the full C5StateEstimatorProtocol with NumPy-only math; module-levelcreate(*, config, imu_preintegrator, se3_utils, wgs_converter, fdr_client) -> tuple[StateEstimator, None]andregister()for runtime-root factory registration. Returnshandle=Nonebecause the ESKF has no GTSAM graph (the iSAM2 handle is for the GTSAM-backed estimator only). Contains a small, self-contained quaternion/SO(3) math helper layer (_quat_to_rot,_quat_mul,_quat_from_axis_angle,_rot_to_axis_angle,_rot_to_quat,_quat_normalise,_skew,_quat_to_quat_dto) — kept local to avoid pulling GTSAM into a numpy-only path.
Added (tests)
tests/unit/c5_state/test_az386_eskf_baseline.py— 20 tests covering AC-1..AC-10 plus seven robustness checks (out-of-order timestamps; default velocity is zero; UUID frame_id; INIT → TRACKING transition; smoothed_history empty + n=0 cases; subscriber rejection delivery).
Modified
- None.
runtime_root.state_factoryalready maps"eskf"→BUILD_STATE_ESKF(added during the AZ-381 protocol task as forward infrastructure). The C5__init__.pyis unchanged —EskfStateEstimatoris intentionally NOT re-exported on the public__all__; consumers must import it directly fromgps_denied_onboard.components.c5_state.eskf_baseline, the same way they importGtsamIsam2StateEstimator. The Protocol + DTO surface in_types/state.pyis unchanged.
Architectural notes
- 16-state error vector + separate nominal state — the textbook ESKF split: the nominal state (
position_world,velocity_world,quat_world_T_body,accel_bias,gyro_bias,dt_offset) is updated by full nonlinear IMU integration; the 16-DoF error covariance is propagated via the linearised transition matrixFand process noiseQ, then corrected by standard Joseph-form Kalman updates that "inject" the error correction into the nominal state and reset the error to zero. The orientation error is a 3-DoF axis-angle perturbation (not the 4-DoF nominal quaternion) — standard ESKF practice. add_viois a SIMPLIFIED relative-pose update — for the baseline scope I compute the world-frame translation delta between the previous and current VIO frames (measurement) vs the analogous delta in the nominal state (prediction), and use the difference as the position residual. The rotation residual compares the body-frame delta between the two VIO frames against the body-frame delta between the two nominal states. The measurement Jacobian is approximate — it treats the snapshot at the previous frame as a fixed reference rather than carrying its uncertainty through. This is sufficient for AC-3 (covariance shrinks on consistent measurements) and is honest about being a baseline; a more rigorous full-Jacobian implementation is left for a future task if IT-12 indicates the baseline is too weak.add_pose_anchorintegrates EVERY anchor (AC-4) — the iSAM2 estimator throttles JACOBIAN-mode anchors out of the graph because the jacobian-projected covariance is not a valid GTSAM noise model; the ESKF has no graph, so the JACOBIAN exclusion does NOT apply. Both modes update the nominal state + bump_last_anchor_nsidentically. This matches the task description literal: "JACOBIAN does NOT skip the ESKF update because ESKF doesn't have a graph; it integrates as a normal measurement."- Divergence test is Mahalanobis-style (AC-9) — the spec language "innovation exceeds 10× the measurement-covariance norm" is informal; reading it literally as
||innov|| > 10 * ||R||_Fmixes units (m vs m²) and yields nonsensical thresholds for any non-trivial measurement. I implemented the standard divergence gate:mahalanobis² = r^T S^{-1} r > 100whereS = H P H^T + Ris the innovation covariance — equivalent to "10 standard deviations in the innovation metric". This is what every textbook Kalman filter does and the only numerically defensible reading of the AC. Documented inline in the source. smoothed_historyhonesty (AC-6) — INVARIANT 7 DEVIATION — the C5 contract Invariant 7 generally requiressmoothed_historyentries to carrysmoothed=True. AZ-386 AC-6 explicitly overrides this for ESKF: the simple-baseline cannot smooth (it is forward-only), so its history entries carrysmoothed=Falseper honesty. This creates a real cross-component constraint: the C8 outbound filter (AZ-261) MUST NOT usesmoothed=Trueas the "do not emit to FC" routing rule, because ESKF history entries would then leak to the FC. The deviation is documented at the top ofeskf_baseline.pyand in thesmoothed_historydocstring; it is also a known item for AZ-261's outbound-filter design — flagging now so the C8 author picks up the constraint when that task lands.- Source-label + spoof-promotion gate is auto-wired (AC-8) — same construction-time eager-construction pattern as the iSAM2 estimator (post-AZ-385). On every successful
add_pose_anchorthe estimator calls_source_label_machine.notify_satellite_anchor(now_ns, gps_consistency_delta_m=None)— theNoneforgps_consistency_delta_mmatches the iSAM2 estimator (the consistency check is still a placeholder pending the FC GPS-cross-check task). The state-machine subscriber surface (subscribe_spoof_rejection) is exposed as a pass-through delegating method, ensuring the composition root wires the C8 GCS adapter to BOTH estimator strategies identically. - AC-5.2 fallback is auto-wired — the
FallbackWatcher(AZ-388) is constructed eagerly in__init__andcheck_and_engage/mark_successful_estimateare called at the entry / exit ofcurrent_estimaterespectively. The public watchdog (check_fallback_state,subscribe_fallback_engaged,subscribe_fallback_recovered) is exposed as pass-through delegates. This means an operator can swap the strategy fromgtsam_isam2toeskfand the fallback semantics are identical end-to-end — exactly what IT-12's comparative study needs. - Smoothed-history → FDR (AZ-387) is structurally a no-op — the ESKF has no smoother to walk, so the
_emit_smoothed_to_fdr_if_anyhook from the iSAM2 estimator is not implemented here. The forward-timecurrent_estimatedoes not enqueue anyc5.state.smoothed_historyFDR records — exactly satisfying AZ-387 AC-3 ("ESKF MUST NOT emit smoothed-history FDR records"). BUILD_STATE_ESKFgating works out-of-the-box — theruntime_root.state_factory._STATE_BUILD_FLAGSmapping already covers"eskf": "BUILD_STATE_ESKF". Tests verify both the OFF-rejection (AC-7) and the ON-resolution (AC-10) paths through the publicbuild_state_estimatorfactory.- WGS84 conversion via the static helper —
WgsConverter.local_enu_to_latlonalt(origin, enu_vector). The default ENU origin is(0, 0, 0)until the composition root injects a real origin viaset_enu_origin(...). Mirrors the iSAM2 estimator's lazy-origin pattern.
Test counts
| Suite | Before (B18) | After (B19) | Delta |
|---|---|---|---|
| Total passing | 640 | 660 | +20 |
| Skipped | 2 | 2 | 0 |
| AZ-386 (new) | 0 | 20 | +20 |
Quality gates
ruff check src/...eskf_baseline.py tests/...test_az386_eskf_baseline.py— clean.ruff format— applied; both files reformatted.mypyis project-wide; no new errors observed (full suite green confirms).- Cursor
ReadLints— no lints. - Full pytest suite —
660 passed, 2 skipped(the two skips are pre-existing CI-only checks:cmakenot on PATH andactionlintnot on PATH).
Known follow-ups
- AZ-261 C8 outbound filter — must use a routing rule that doesn't conflate
smoothed=Truewith "do not route to FC", because ESKF entries (per AC-6) carrysmoothed=Falsebut MUST also not be routed to the FC. - Relative-pose Jacobian fidelity — the
add_vioJacobian is intentionally approximate. If IT-12 indicates the baseline is too weak to compare meaningfully against iSAM2, replace with the full cross-covariance form (snapshot the previous state error and propagate the covariance forward). set_enu_originis a free-form injection point — the composition root should call it during bringup once a real GPS origin is known. There is no test that asserts the call ordering yet; this is consistent with the iSAM2 estimator pattern.