[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
@@ -24,7 +24,12 @@ The shared `ImuPreintegrator` (AZ-276), `SE3Utils` (AZ-277), and `WgsConverter`
@runtime_checkable
class StateEstimator(Protocol):
# AZ-490 / ADR-010: operator-provided warm-start. MUST be called before any add_*.
def set_takeoff_origin(self, origin: LatLonAlt, sigma_m: float) -> None: ...
def set_takeoff_origin(
self,
origin: LatLonAlt,
sigma_horiz_m: float,
sigma_vert_m: float,
) -> None: ...
def add_vio(self, vio: VioOutput) -> None: ...
def add_pose_anchor(self, pose: PoseEstimate) -> None: ...
@@ -46,7 +51,7 @@ class StateEstimator(Protocol):
8. **Spoof-rejection events ALWAYS land in FDR + GCS STATUSTEXT** — never silent (R07; C5-ST-01).
9. **AC-5.2 fallback on 3 s no-estimate** — if `current_estimate()` would raise OR the keyframe window is empty for ≥3 s, downstream C8 emits FC IMU-only.
10. **`covariance_6x6` is always SPD** — both strategies enforce; on numerical failure raise `EstimatorFatalError`.
11. **`set_takeoff_origin(origin, sigma_m)` is a `INIT`-state-only entrypoint** (AZ-490, ADR-010). Calling it after the estimator has transitioned to `TRACKING` raises `StateEstimatorConfigError`. Inside `INIT` it is idempotent — re-invocation overwrites the prior with the new origin + sigma. `sigma_m` MUST be positive and finite; otherwise raise `StateEstimatorConfigError`. The origin is consumed as a Bayesian prior on the initial pose key (iSAM2: `PriorFactorPose3` with covariance = `diag(sigma_m^2, sigma_m^2, (2*sigma_m)^2, ...)` in ENU position + orientation order; ESKF: nominal-state seed + position-block covariance = `sigma_m^2 * I_3`).
11. **`set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` is a cold-start-window-only entrypoint** (AZ-490, ADR-010). The cold-start window closes on the first `add_*` call; calling `set_takeoff_origin` after that raises `EstimatorAlreadyStartedError` (subclass of `StateEstimatorConfigError`). Inside the cold-start window the call is **strictly idempotent on identical args** (re-invocation with byte-equal `origin`/`sigma_horiz_m`/`sigma_vert_m` is a no-op); a re-invocation with **different** args raises `StateEstimatorConfigError`. Both sigmas MUST be positive and finite; the origin MUST be inside WGS-84 bounds (lat ∈ [-90, 90], lon ∈ [-180, 180], all components finite); otherwise raise `StateEstimatorConfigError`. The origin is consumed as a Bayesian prior on the first pose key (iSAM2: `PriorFactorPose3` at `Pose3.Identity()` with diagonal sigmas `[5°, 5°, 5°, sigma_horiz_m, sigma_horiz_m, sigma_vert_m]` in rotation+translation order; the operator origin BECOMES the local-ENU (0,0,0) anchor. ESKF: nominal-state position seeded to (0,0,0) + position block of error covariance set to `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`). One `c5.cold_start_origin.set` FDR record is emitted per estimator (with `source="manifest"` from this entrypoint, or `source="fc_ekf"` from the legacy first-frame path when no operator origin was supplied).
12. **Spoof-promotion gate has THREE clauses, not two** — Principle #11 amended. Re-promote a previously-spoofed FC GPS source only when ALL of: (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ `spoof_promotion_min_stable_s`, (ii) the next satellite-anchored frame agrees with the FC GPS within `spoof_promotion_visual_consistency_tol_m`, AND (iii) the FC's reported position is within `spoof_promotion_bounded_delta_m` (default 200 m) of the companion's last emitted `PoseEstimate`. The bounded-delta clause also gates the takeoff path when a Manifest `takeoff_origin` is present.
### DTOs (in `_types/state.py`)
@@ -135,10 +140,11 @@ Config schema additions:
| 8 | Spoof-rejection logging | FDR + GCS STATUSTEXT both fire on every gate decision |
| 9 | AC-5.2 timeout | 3 s no estimate → fallback signal emitted |
| 10 | SPD covariance | every emitted `covariance_6x6` is SPD |
| 11a | `set_takeoff_origin` after `TRACKING` | raises `StateEstimatorConfigError` |
| 11b | `set_takeoff_origin` with `sigma_m <= 0` or non-finite | raises `StateEstimatorConfigError` |
| 11c | `set_takeoff_origin` twice in `INIT` | second call wins; covariance updated to new sigma |
| 11d | First `current_estimate` after `set_takeoff_origin` + no sensor samples | returns `EstimatorOutput` with `position_wgs84 == origin`, `covariance_6x6` reflecting `sigma_m^2` in the position block |
| 11a | `set_takeoff_origin` after first `add_*` call | raises `EstimatorAlreadyStartedError` (subclass of `StateEstimatorConfigError`) |
| 11b | `set_takeoff_origin` with non-positive / non-finite sigma OR out-of-bounds `LatLonAlt` | raises `StateEstimatorConfigError` |
| 11c | `set_takeoff_origin` twice in cold-start window with **identical** args | second call is a no-op; no extra FDR record |
| 11d | `set_takeoff_origin` twice in cold-start window with **different** args | raises `StateEstimatorConfigError` (names both prev/new args) |
| 11e | First `current_estimate` after `set_takeoff_origin` + no sensor samples | returns `EstimatorOutput` with `position_wgs84 == origin`, position block of `covariance_6x6` reflecting `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)` |
| 12 | Bounded-delta gate | FC GPS frame with |Δ| > 200 m vs last emitted `PoseEstimate` is rejected even when stable + non-spoofed for ≥ 10 s + visual-consistent |
## Producer-task / consumer-task split