Files
Oleksandr Bezdieniezhnykh 8a83166261 [AZ-490] C5 set_takeoff_origin entrypoint + bounded-delta GPS gate
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>
2026-05-12 02:53:58 +03:00

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

  • StateEstimator Protocol (src/gps_denied_onboard/components/c5_state/interface.py) gains set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None. Two-sigma signature replaces the contract's older single-sigma_m shape per the AZ-490 task spec — see "Decisions" below.
  • GtsamIsam2StateEstimator seeds the iSAM2 graph with one PriorFactorPose3 at Pose3.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 single handle.update.
  • EskfStateEstimator zeros _nominal_pos and overwrites the position block of _P to diag(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. Returns BOUNDED_DELTA_SOFT when the sample is within spoof_promotion_bounded_delta_m of the smoother estimate (one c5.gps_bounded_delta.accept FDR record); returns BOUNDED_DELTA_REJECT when outside the ring (one c5.gps_bounded_delta.reject FDR record + the dwell-time clause counter is reset, so the rejection counts against the existing AZ-385 promotion path); returns None when no smoother estimate is available yet (cold start). Distance is computed via WgsConverter.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 | None as the C8-inbound seam — looks up the smoother's current latlon (best-effort) and delegates to the source-label machine.
  • ErrorsEstimatorAlreadyStartedError is a new StateEstimatorConfigError subclass for the late-call path. Existing except StateEstimatorConfigError callers still catch it.
  • ConfigC5StateConfig gains spoof_promotion_bounded_delta_m=200.0, default_takeoff_origin_sigma_horiz_m=5.0, default_takeoff_origin_sigma_vert_m=10.0 with __post_init__ positivity validation matching the existing pattern.
  • DTO — new GpsSample in src/gps_denied_onboard/_types/fc.py carries LatLonAlt + captured_at: int. Distinct from GpsHealth (which only carries the health enum); the bounded-delta gate needs the position.
  • WgsConverter — new static WgsConverter.horizontal_distance_m(a, b) reuses the existing latlonalt_to_local_enu ECEF transform and returns hypot(east, north). Geodetically correct (NOT haversine on equirectangular per AC-15).
  • FDR schema — four new kinds registered in KNOWN_PAYLOAD_KEYS with 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_window helper added to both estimators. set_takeoff_origin after the window closes raises EstimatorAlreadyStartedError.
  • Exactly one c5.cold_start_origin.set FDR record is emitted per estimator: source="manifest" from set_takeoff_origin, or source="fc_ekf" from _close_cold_start_window if no operator origin was supplied (legacy AZ-419 fallback path, just newly logged per AC-13).
  • Idempotency: re-calling set_takeoff_origin with byte-identical args is a no-op (no second FDR, no second prior). Re-calling with different args raises StateEstimatorConfigError naming 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. New EstimatorAlreadyStartedError documented.
  • _docs/02_document/architecture.md — ADR-010 narrative + TakeoffOrigin glossary 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.mdTakeoff origin entry 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; ESKF notify_gps_sample happy path; protocol conformance for both impls). All pass.
  • tests/unit/c5_state/test_az381_state_protocol.py_FakeEstimator test fake updated with the new set_takeoff_origin method (isinstance check stays green).
  • tests/unit/c5_state/test_az385_source_label_spoof_gate.py — fixture _make_sm now passes spoof_promotion_bounded_delta_m=200.0 to 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_payload extended with the four new C5 kinds; the KNOWN_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 new default_takeoff_origin_sigma_{horiz,vert}_m config defaults. The contract document was updated to match.
  • EstimatorAlreadyStartedError as a StateEstimatorConfigError subclass, not a peer: existing except StateEstimatorConfigError callers 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_SOFT is a private string constant, not a new PoseSourceLabel enum value: PoseSourceLabel is a shared C4/C5 public surface; adding a value would ripple through every consumer (test fixtures, FDR state.tick schema, 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_sm for the ESKF / iSAM2 dispatch return value but never enters the public EstimatorOutput.source_label field.
  • WgsConverter.horizontal_distance_m instead of a new vincenty_distance method: AC-15's text references "Vincenty distance" but pyproj's ECEF chain (which our latlonalt_to_local_enu already 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 against pyproj.Geod.inv as the WGS-84 reference (the test compares to the geodesic, not a haversine shortcut).
  • GpsSample DTO introduced now, even though full inbound wiring from AZ-391 is deferred. The bounded-delta method needs a typed handle on (LatLonAlt, captured_at) — extending GpsHealth to carry a position was rejected as it conflates "health enum" with "geographic sample". The notify_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_ns on 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_args is duplicated between gtsam_isam2_estimator.py (static method) and eskf_baseline.py (module-level helper). Same source code. Can be lifted to a shared _validation.py if 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 as GpsHealth only; GpsSample is 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]