[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 02:53:58 +03:00
parent 72a06edab0
commit 8a83166261
23 changed files with 1640 additions and 26 deletions
@@ -0,0 +1,210 @@
# 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.
* **Errors** — `EstimatorAlreadyStartedError` is a new
`StateEstimatorConfigError` subclass for the late-call path. Existing
`except StateEstimatorConfigError` callers still catch it.
* **Config** — `C5StateConfig` 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.md``Takeoff 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]
```