Add operator warm-start path to C5 StateEstimator Protocol and both
implementations (GtsamIsam2StateEstimator, EskfStateEstimator), plus
the third clause of the AZ-385 spoof-promotion gate.
- StateEstimator Protocol: set_takeoff_origin(origin, sigma_horiz_m,
sigma_vert_m) -> None.
- iSAM2: PriorFactorPose3 at origin with diagonal sigmas, single
isam2.update().
- ESKF: zero _nominal_pos, overwrite _P position block with sigma**2.
- SourceLabelStateMachine.process_gps_sample bounded-delta clause:
WgsConverter.horizontal_distance_m vs smoother estimate; reject
resets the dwell-time counter so AZ-385 cannot re-promote off bad
GPS.
- New EstimatorAlreadyStartedError (StateEstimatorConfigError
subclass) on late call after first add_*.
- C5StateConfig: spoof_promotion_bounded_delta_m=200,
default_takeoff_origin_sigma_horiz_m=5,
default_takeoff_origin_sigma_vert_m=10.
- New GpsSample DTO + WgsConverter.horizontal_distance_m helper.
- 4 new FDR kinds (cold_start_origin.{set,unavailable},
gps_bounded_delta.{accept,reject}) registered in AZ-272 schema.
- 33 new unit tests cover AC-1..AC-15; full repo 750 passed / 2
skipped (pre-existing CI tooling skips).
Docs synced: protocol contract, C5 component description,
architecture, glossary, system-flows, C10 provisioning description.
Co-authored-by: Cursor <cursoragent@cursor.com>
11 KiB
Batch 22 — AZ-490 C5 set_takeoff_origin + bounded-delta gate
Date: 2026-05-12 Tracker: Jira AZ-490 (Epic AZ-260 / E-C5) — transitioned To Do → In Progress → Done. Cycle: 1 Status: complete; 33 new unit tests green; full repo 750 passed / 2 skipped (pre-existing CI tooling skips).
Scope landed
AZ-490 delivers ADR-010's airborne consumer of the operator-supplied takeoff
origin (the producer side landed in batch 21 as AZ-489). C5's StateEstimator
Protocol gains the pre-takeoff set_takeoff_origin entrypoint, both concrete
implementations (GtsamIsam2StateEstimator + EskfStateEstimator) honour it
with the same semantics, and the AZ-385 source-label state machine grows the
third — bounded-delta — clause of Principle #11. Four FDR record kinds
(c5.cold_start_origin.set, c5.cold_start_origin.unavailable,
c5.gps_bounded_delta.accept, c5.gps_bounded_delta.reject) are now part of
the AZ-272 schema and round-trip cleanly.
The composition-root F2 wiring of manifest.takeoff_origin → estimator.set_takeoff_origin
remains explicitly out of scope (owned by the AZ-381 owner as a follow-up commit
to keep this task focused on the C5-side contract per the spec).
Public surface
StateEstimatorProtocol (src/gps_denied_onboard/components/c5_state/interface.py) gainsset_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None. Two-sigma signature replaces the contract's older single-sigma_mshape per the AZ-490 task spec — see "Decisions" below.GtsamIsam2StateEstimatorseeds the iSAM2 graph with onePriorFactorPose3atPose3.Identity()(operator origin BECOMES the local ENU (0,0,0) anchor) with diagonal sigmas[5°, 5°, 5°, sigma_horiz_m, sigma_horiz_m, sigma_vert_m]and drives a singlehandle.update.EskfStateEstimatorzeros_nominal_posand overwrites the position block of_Ptodiag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²), defensively re-symmetrising afterwards.SourceLabelStateMachine.process_gps_sample(sample, smoother_estimate, now_ns)is the third clause of the spoof-promotion gate. ReturnsBOUNDED_DELTA_SOFTwhen the sample is withinspoof_promotion_bounded_delta_mof the smoother estimate (onec5.gps_bounded_delta.acceptFDR record); returnsBOUNDED_DELTA_REJECTwhen outside the ring (onec5.gps_bounded_delta.rejectFDR record + the dwell-time clause counter is reset, so the rejection counts against the existing AZ-385 promotion path); returnsNonewhen no smoother estimate is available yet (cold start). Distance is computed viaWgsConverter.horizontal_distance_m, matching the WGS-84 geodesic (pyproj's ECEF chain) within sub-mm at the bounded-delta operating range.- Both estimators expose
notify_gps_sample(sample: GpsSample) -> str | Noneas the C8-inbound seam — looks up the smoother's current latlon (best-effort) and delegates to the source-label machine. - Errors —
EstimatorAlreadyStartedErroris a newStateEstimatorConfigErrorsubclass for the late-call path. Existingexcept StateEstimatorConfigErrorcallers still catch it. - Config —
C5StateConfiggainsspoof_promotion_bounded_delta_m=200.0,default_takeoff_origin_sigma_horiz_m=5.0,default_takeoff_origin_sigma_vert_m=10.0with__post_init__positivity validation matching the existing pattern. - DTO — new
GpsSampleinsrc/gps_denied_onboard/_types/fc.pycarriesLatLonAlt+captured_at: int. Distinct fromGpsHealth(which only carries the health enum); the bounded-delta gate needs the position. - WgsConverter — new static
WgsConverter.horizontal_distance_m(a, b)reuses the existinglatlonalt_to_local_enuECEF transform and returnshypot(east, north). Geodetically correct (NOT haversine on equirectangular per AC-15). - FDR schema — four new kinds registered in
KNOWN_PAYLOAD_KEYSwith explicit per-key allow-lists; AZ-268 contract test (test_az272_fdr_record_schema.py) was extended with their fixture payloads and now round-trips them.
Cold-start ledger
- The cold-start window closes on the first
add_*call (vio / pose_anchor / fc_imu) via the new_close_cold_start_windowhelper added to both estimators.set_takeoff_originafter the window closes raisesEstimatorAlreadyStartedError. - Exactly one
c5.cold_start_origin.setFDR record is emitted per estimator:source="manifest"fromset_takeoff_origin, orsource="fc_ekf"from_close_cold_start_windowif no operator origin was supplied (legacy AZ-419 fallback path, just newly logged per AC-13). - Idempotency: re-calling
set_takeoff_originwith byte-identical args is a no-op (no second FDR, no second prior). Re-calling with different args raisesStateEstimatorConfigErrornaming both prev/new args.
Doc updates
_docs/02_document/contracts/c5_state/state_estimator_protocol.md— signature widened to two sigmas; Invariants 11a/11b/11c/11d/11e refined to match the implemented contract; Invariant 12 (bounded-delta gate) unchanged._docs/02_document/components/07_c5_state/description.md— params column, state-management cold-start ladder, and error-handling table updated. NewEstimatorAlreadyStartedErrordocumented._docs/02_document/architecture.md— ADR-010 narrative +TakeoffOriginglossary line + Principle #14 narrative updated._docs/02_document/system-flows.md— F2 sequence diagram + flow chart + data-table all carry the two-sigma signature._docs/02_document/glossary.md—Takeoff originentry updated._docs/02_document/components/11_c10_provisioning/description.md— one prose mention updated.
Tests
tests/unit/c5_state/test_az490_set_takeoff_origin.py— 33 tests covering AC-1..AC-15 plus three additional edge cases (no-smoother bounded-delta returns None; ESKFnotify_gps_samplehappy path; protocol conformance for both impls). All pass.tests/unit/c5_state/test_az381_state_protocol.py—_FakeEstimatortest fake updated with the newset_takeoff_originmethod (isinstance check stays green).tests/unit/c5_state/test_az385_source_label_spoof_gate.py— fixture_make_smnow passesspoof_promotion_bounded_delta_m=200.0to the SM constructor; existing AC-1..AC-12 still pass (AC-14 of AZ-490 — additive clause).tests/unit/test_az272_fdr_record_schema.py—_kind_payloadextended with the four new C5 kinds; theKNOWN_KINDS-parametrised round-trip test now covers them automatically.
Full repo: 750 passed / 2 skipped (the two skips are pre-existing
CI-only tooling skips: cmake and actionlint not on PATH locally).
Decisions
- Two-sigma signature, not single-sigma: the C5 contract's older
signature (
sigma_m: float) lost a useful axis — operator-supplied manifests typically distinguish horizontal (GPS) from vertical (barometric / GPS) uncertainty by an order of magnitude. The task spec's two-sigma form is more general and aligns with the newdefault_takeoff_origin_sigma_{horiz,vert}_mconfig defaults. The contract document was updated to match. EstimatorAlreadyStartedErroras aStateEstimatorConfigErrorsubclass, not a peer: existingexcept StateEstimatorConfigErrorcallers should still catch it; a new exception name is justified by the semantic distinction (you called the method at the wrong time, vs. you called it with bad args), but the inheritance keeps the C5 error hierarchy three-rooted (Degraded / Fatal / Config).BOUNDED_DELTA_SOFTis a private string constant, not a newPoseSourceLabelenum value:PoseSourceLabelis a shared C4/C5 public surface; adding a value would ripple through every consumer (test fixtures, FDRstate.tickschema, C8 outbound formatter) for a soft- admission outcome that doesn't change the externally observable pose provenance. The string constant is exposed from_source_label_smfor the ESKF / iSAM2 dispatch return value but never enters the publicEstimatorOutput.source_labelfield.WgsConverter.horizontal_distance_minstead of a newvincenty_distancemethod: AC-15's text references "Vincenty distance" but pyproj's ECEF chain (which ourlatlonalt_to_local_enualready uses) matches Vincenty within sub-mm at the bounded-delta operating range (<= ~1 km), so the algorithmic family is geodetically correct. The new helper makes the intent explicit. AC-15 is satisfied againstpyproj.Geod.invas the WGS-84 reference (the test compares to the geodesic, not a haversine shortcut).GpsSampleDTO introduced now, even though full inbound wiring from AZ-391 is deferred. The bounded-delta method needs a typed handle on(LatLonAlt, captured_at)— extendingGpsHealthto carry a position was rejected as it conflates "health enum" with "geographic sample". Thenotify_gps_sample(sample)method on both estimators is the composition-root seam; AZ-391 plumbing arrives in a later task.- Bounded-delta reject also resets the dwell-time clause: a wildly-off
GPS sample is a strong signal the FC stream is unreliable, even when
gps_health == STABLE_NON_SPOOFED. Resetting_gps_health_stable_since_nson reject means the AZ-385 spoof gate can't re-promote off the back of bad data. AC-10 verifies this.
Self-review findings
- Low / Code-quality:
_validate_takeoff_origin_argsis duplicated betweengtsam_isam2_estimator.py(static method) andeskf_baseline.py(module-level helper). Same source code. Can be lifted to a shared_validation.pyif the duplication grows. Surgical for now — intentionally not refactored to keep the AZ-490 diff scoped.
No High / Critical findings. PASS_WITH_WARNINGS verdict.
Out-of-scope (explicit)
- Composition-root F2 wiring of
manifest.takeoff_origin → estimator.set_takeoff_origin(owned by the AZ-381 owner as a follow-up commit per the task spec). - AZ-391 inbound-path wiring of
GpsSample(FC GPS observations currently flow asGpsHealthonly;GpsSampleis a forward-compatible DTO that a later task will populate from the C8 inbound subscription). - C10 Manifest schema extension to include
takeoff_origin(owned by AZ-323 / AZ-324; the consumer side is in place). - AZ-419's BBT cold-start path validation (FT-P-11 — owned separately).
Files touched
src/gps_denied_onboard/_types/fc.py
src/gps_denied_onboard/components/c5_state/__init__.py
src/gps_denied_onboard/components/c5_state/_source_label_sm.py
src/gps_denied_onboard/components/c5_state/config.py
src/gps_denied_onboard/components/c5_state/errors.py
src/gps_denied_onboard/components/c5_state/eskf_baseline.py
src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py
src/gps_denied_onboard/components/c5_state/interface.py
src/gps_denied_onboard/fdr_client/records.py
src/gps_denied_onboard/helpers/wgs_converter.py
tests/unit/c5_state/test_az381_state_protocol.py
tests/unit/c5_state/test_az385_source_label_spoof_gate.py
tests/unit/c5_state/test_az490_set_takeoff_origin.py [new]
tests/unit/test_az272_fdr_record_schema.py
_docs/02_document/architecture.md
_docs/02_document/components/07_c5_state/description.md
_docs/02_document/components/11_c10_provisioning/description.md
_docs/02_document/contracts/c5_state/state_estimator_protocol.md
_docs/02_document/glossary.md
_docs/02_document/system-flows.md
_docs/03_implementation/batch_22_cycle1_report.md [new]