[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
+4 -4
View File
@@ -40,7 +40,7 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
9. **Two execution tiers** (Tier-1 workstation Docker = fast/cheap; Tier-2 Jetson hardware = AC-bound) appear in the deployment plan and CI matrix per finding F6.
10. **Camera intrinsics and full-altitude footage are calibration prerequisites**, not implementation gaps. Production accuracy claims are gated on D-PROJ-1 closure (hybrid factory + checkerboard refinement). Test fixtures use `adti26` calibration sourced from public/factory references.
11. **Spoofed GPS never re-enters the estimator** unless the FC GPS report passes a three-part gate (AC-NEW-8 + AZ-490 follow-up): (a) FC GPS health stable + non-spoofed for ≥ 10 s, (b) a visual/satellite consistency check has succeeded on the next anchor frame, AND (c) the FC's reported position is within ≤ 200 m of the companion's last emitted `PoseEstimate`. The third clause is the **mid-flight bounded-delta gate** — even a "stable, non-spoofed" GPS frame is rejected if it disagrees with the companion's posterior by more than the configurable budget. Real GPS that passes the gate is fused via `add_pose_anchor` with the FC's covariance (treated as one more anchor source, never overriding the visual pipeline without the gate).
14. **Operator-planned mission is the primary cold-start trust anchor**, not the FC EKF (AZ-490 follow-up). The operator authors the route in the parent-suite **Mission Planner UI** (`suite/ui`), the route persists in the parent-suite **`flights` REST service** (`suite/flights`), and C12 (operator tooling) reads the `Flight` from that service to: (a) derive the cache bbox as the envelope of the waypoint lat/lon plus a configurable buffer, (b) extract the first-ordered waypoint as the **takeoff origin** (lat / lon / alt), and (c) bake the takeoff origin into the C10 Manifest so the airborne C5 can warm-start from it via `set_takeoff_origin(origin, sigma_m)` **before** any FC IMU / VIO sample arrives. This unblocks the GPS-jammed-at-takeoff scenario the FC-EKF-only cold-start path (AZ-419 today) cannot handle. The FC EKF's last valid GPS becomes a **secondary** cold-start input — used only when the operator origin is missing from the Manifest OR when the FC EKF reading passes the same bounded-delta consistency check against the operator origin.
14. **Operator-planned mission is the primary cold-start trust anchor**, not the FC EKF (AZ-490 follow-up). The operator authors the route in the parent-suite **Mission Planner UI** (`suite/ui`), the route persists in the parent-suite **`flights` REST service** (`suite/flights`), and C12 (operator tooling) reads the `Flight` from that service to: (a) derive the cache bbox as the envelope of the waypoint lat/lon plus a configurable buffer, (b) extract the first-ordered waypoint as the **takeoff origin** (lat / lon / alt), and (c) bake the takeoff origin into the C10 Manifest so the airborne C5 can warm-start from it via `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` **before** any FC IMU / VIO sample arrives. This unblocks the GPS-jammed-at-takeoff scenario the FC-EKF-only cold-start path (AZ-419 today) cannot handle. The FC EKF's last valid GPS becomes a **secondary** cold-start input — used only when the operator origin is missing from the Manifest OR when the FC EKF reading passes the same bounded-delta consistency check against the operator origin.
12. **AC-4.5 is internal smoothing only.** GTSAM iSAM2 retroactively refines past keyframes onboard and emits the corrected current frame; the FC log is forward-time only — neither ArduPilot nor iNav supports FC-side retroactive correction (Mode B Fact #107).
13. **Interface-first components with constructor-injected dependencies.** Every component is **defined as an interface (Python `Protocol` or `ABC`) before any concrete implementation exists**, lives in its **own folder under `src/components/<component>/`**, and is wired together via **constructor injection** at a single composition root. Components never reach out to a global registry, a singleton, or `import` a sibling component's concrete class directly — they receive their collaborators as `__init__` arguments typed against the sibling's interface. Multiple interchangeable implementations of the same interface MUST be supported by design (e.g., C1 has three `VioStrategy` implementations; C2 has UltraVPR + MegaLoc + MixVPR + … behind a single `VprStrategy`; C8 has two FC-adapter implementations behind a single `FcAdapter`). Selection happens once, at startup, by config; the composition root resolves config → concrete implementation → wires the graph; the rest of the runtime sees only interfaces. **Side benefit (NOTE)**: this design also gives the project **packaging optionality** — different combinations of `BUILD_*` flags can produce binaries tailored to specific deployment targets, customer bundles, or (if/when relevant later) end-product licensing strategies, **without any source-level change in application code**. That optionality is a *consequence* of the interface-first design, not a driver — the architectural decisions in this document are made on technical grounds; component licenses do not influence them. See ADR-002 § Consequences and ADR-009.
@@ -207,7 +207,7 @@ source repo
| `SectorClassification` | `active_conflict | stable_rear` per area, drives freshness threshold | C12 (operator-set) → C6, C10 |
| `Flight` | Operator-planned mission: ordered `Waypoint` list + metadata, persisted in the parent-suite `flights` REST service. Read by C12 via `FlightsApiClient`; never reached from the airborne companion | External (`suite/flights`) → C12 |
| `Waypoint` | Ordered `(lat, lon, alt, objective, source)` entry inside a `Flight`. C12 envelopes waypoint lat/lon → bbox; first-ordered waypoint → takeoff origin | External (`suite/flights`) → C12 |
| `TakeoffOrigin` | `LatLonAlt` carried in the C10 Manifest; baked in by C12 at build time from `Flight.waypoints[0]`; consumed at boot by C5 via `set_takeoff_origin(origin, sigma_m)` (AZ-490) | C12 → C10 Manifest → C5 |
| `TakeoffOrigin` | `LatLonAlt` carried in the C10 Manifest; baked in by C12 at build time from `Flight.waypoints[0]`; consumed at boot by C5 via `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` (AZ-490) | C12 → C10 Manifest → C5 |
**Key relationships**:
@@ -619,7 +619,7 @@ This decision is made on **technical grounds only**. Component licenses (BSD/Apa
1. **`Flight` is read pre-flight, not in-flight.** C12 (the operator-side tool, separate binary from the airborne companion — per ADR-002) calls the parent-suite `flights` REST service via a typed client (AZ-489 `FlightsApiClient`) when the operator runs `gps-denied-cli build-cache --flight-id <Guid>`. An offline path (`--flight-file <path>`) reads the same DTO shape from a JSON export so the workflow survives operator workstations that have no path to the flights service. The companion binary **never** depends on the flights service at runtime (Principle #9 — denied-environment operation).
2. **C12 derives bbox + takeoff origin from the `Flight`.** The bbox is the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, AZ-489 AC-3). The takeoff origin is `Flight.waypoints[0].(lat, lon, alt)` — the operator's authored launch point.
3. **Both fields are baked into the C10 Manifest.** `BuildRequest` and `Manifest` carry `takeoff_origin: LatLonAlt | None` (AZ-323 / AZ-325 / AZ-324 amendments). The hash that drives D-C10-1 idempotence includes `takeoff_origin`, so a re-plan of the route produces a new cache identity and the verifier (AZ-324) rejects a mismatched cache at boot.
4. **C5 consumes the origin before any sensor sample.** The companion's composition root reads `takeoff_origin` from the cache manifest at boot and invokes `set_takeoff_origin(origin, sigma_m)` on the active `StateEstimator` (AZ-490) **before** the first `add_vio` / `add_fc_imu` call. Both `GtsamIsam2StateEstimator` and `EskfStateEstimator` accept the origin as a Bayesian prior — iSAM2 attaches a `PriorFactorPose3` on the initial pose key with covariance derived from `sigma_m` (default 50 m horizontal, 100 m vertical); ESKF seeds the nominal position and writes the position block of the error covariance to match `sigma_m^2`.
4. **C5 consumes the origin before any sensor sample.** The companion's composition root reads `takeoff_origin` from the cache manifest at boot and invokes `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` on the active `StateEstimator` (AZ-490) **before** the first `add_vio` / `add_fc_imu` call. Both `GtsamIsam2StateEstimator` and `EskfStateEstimator` accept the origin as a Bayesian prior — iSAM2 attaches a `PriorFactorPose3` at `Pose3.Identity()` (the 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]`; ESKF seeds the nominal position to (0,0,0) and writes the position block of the error covariance to `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`. Defaults are `sigma_horiz_m = 5.0 m`, `sigma_vert_m = 10.0 m` from `C5StateConfig`.
5. **FC GPS is a secondary, gated input.** If the FC EKF later produces a GPS reading (in-flight or at takeoff), it is fused through the existing `add_pose_anchor` machinery only after passing the three-part gate of Principle #11 — **including the ≤ 200 m bounded-delta check against the companion's last emitted `PoseEstimate`**. Real GPS that passes the gate is one more measurement, never an override.
6. **Failure modes.** If the Manifest has no `takeoff_origin` AND the FC EKF has no usable GPS at takeoff, C5 stays in `INITIALIZING` and the FC adapter (C8) emits a non-fused source label; the FT-P-11 takeoff-abort policy (AZ-419 amended) applies. If the Manifest has `takeoff_origin` AND the FC EKF GPS is wildly inconsistent with it at takeoff (e.g., > 200 m), the operator origin wins and the FC GPS is logged as suspect — this is the GPS-spoofed-at-takeoff case and is the entire point of this ADR.
@@ -634,7 +634,7 @@ This decision is made on **technical grounds only**. Component licenses (BSD/Apa
- AZ-419 (FT-P-11) is amended: the primary cold-start path is operator-origin-from-manifest; FC-EKF-GPS is the fallback path with its own sub-AC.
- C10 contracts gain a `takeoff_origin` field in `BuildRequest`, `Manifest`, and the verifier's validation set (AZ-323 / AZ-325 / AZ-324). Contract version bumps to v1.1.0.
- C5 gains a `set_takeoff_origin(origin, sigma_m)` method on the `StateEstimator` protocol (AZ-490). Protocol contract version bumps to v1.1.0.
- C5 gains a `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` method on the `StateEstimator` protocol (AZ-490). Protocol contract version bumps to v1.1.0.
- C12 gains the `FlightsApiClient` boundary + offline `--flight-file` path (AZ-489).
- Principle #11 (the spoofed-GPS gate) is extended with the bounded-delta clause; the gate now serves both takeoff and mid-flight.
- The companion binary's network surface is unchanged — only C12 (operator-side, separate binary) talks to the flights service.
@@ -22,7 +22,7 @@
| Method | Input | Output | Async | Error Types |
|--------|-------|--------|-------|-------------|
| `set_takeoff_origin` (AZ-490, ADR-010) | `origin: LatLonAlt, sigma_m: float` | `None` | No | `EstimatorConfigError`, `EstimatorFatalError` |
| `set_takeoff_origin` (AZ-490, ADR-010) | `origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float` | `None` | No | `StateEstimatorConfigError`, `EstimatorAlreadyStartedError` |
| `add_vio` | `VioOutput` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
| `add_pose_anchor` | `PoseEstimate` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
| `add_fc_imu` | `ImuWindow` | `None` | No | `EstimatorDegradedError` |
@@ -77,7 +77,7 @@ C5 is bounded by design — no unbounded growth.
**State Management**:
- iSAM2 graph + Values + Marginals lifecycle for the flight.
- Cold-start ladder (ADR-010, AZ-490): `set_takeoff_origin(origin, sigma_m)` MUST be invoked before any `add_vio` / `add_fc_imu` / `add_pose_anchor` call. iSAM2 attaches a `PriorFactorPose3` on the initial pose key with covariance derived from `sigma_m` (default 50 m horizontal, 100 m vertical); ESKF seeds the nominal position and writes the position-block of the error covariance to `sigma_m^2`. The method is idempotent within `INIT` state (re-invocation overwrites the prior); once the estimator transitions to `TRACKING`, further calls raise `EstimatorConfigError`.
- Cold-start ladder (ADR-010, AZ-490): `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` MUST be invoked before any `add_vio` / `add_fc_imu` / `add_pose_anchor` call. The cold-start window closes on the first `add_*` call. iSAM2 attaches a `PriorFactorPose3` at `Pose3.Identity()` (operator origin BECOMES local-ENU (0,0,0)) with diagonal sigmas `[5°, 5°, 5°, sigma_horiz_m, sigma_horiz_m, sigma_vert_m]`; ESKF seeds the nominal position to (0,0,0) and writes the position-block of the error covariance to `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`. The method is **strictly idempotent on identical args** — re-invocation with byte-equal `(origin, sigma_horiz_m, sigma_vert_m)` is a no-op; re-invocation with **different** args raises `StateEstimatorConfigError`. Once the cold-start window closes, further calls raise `EstimatorAlreadyStartedError` (subclass of `StateEstimatorConfigError`). Defaults `default_takeoff_origin_sigma_horiz_m = 5.0`, `default_takeoff_origin_sigma_vert_m = 10.0` live in `C5StateConfig`.
- Source-label state machine: tracks the AC-NEW-2 / AC-NEW-8 spoofing-promotion gate (≥10 s + visual consistency check + ≤ 200 m bounded-delta before re-promoting a previously-spoofed FC GPS source).
- Last-anchor-age timer for AC-1.3 binning.
@@ -90,7 +90,7 @@ C5 is bounded by design — no unbounded growth.
| Eigen | matches GTSAM | Lie-algebra math |
**Error Handling Strategy**:
- `EstimatorConfigError`: `set_takeoff_origin` called after `TRACKING` state, OR called with a malformed `LatLonAlt` / non-positive `sigma_m`. Caller must surface to operator; takeoff blocked.
- `StateEstimatorConfigError`: `set_takeoff_origin` called with a malformed `LatLonAlt` (out of WGS-84 bounds / non-finite) OR with non-positive / non-finite sigmas, OR re-called inside the cold-start window with conflicting args. `EstimatorAlreadyStartedError` (a `StateEstimatorConfigError` subclass): `set_takeoff_origin` called after the first `add_*` call sealed the cold-start window. Caller must surface to operator; takeoff blocked.
- `EstimatorDegradedError`: factor add yielded poor convergence; covariance inflated; emit `EstimatorOutput` with degraded label.
- `EstimatorFatalError`: iSAM2 numerical failure, KEYFRAME_LIMIT exceeded, etc.; emit no `EstimatorOutput` for this tick. AC-5.2 fallback (3 s no estimate → FC IMU-only) applies.
- Spoof-promotion gate (Principle #11 amended, AZ-385 + AZ-490 follow-up): never re-introduce a previously-spoofed FC GPS source until ALL THREE hold — (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ 10 s, (ii) the next satellite-anchored frame agrees with the FC GPS within a configurable tolerance, AND (iii) the FC's reported position is within ≤ 200 m of the companion's last emitted `PoseEstimate`. The same gate is applied at takeoff when a Manifest `takeoff_origin` is present: an FC GPS reading that disagrees with the operator origin by > 200 m is logged as suspect and the operator origin wins. Document every reject in FDR + GCS STATUSTEXT.
@@ -2,7 +2,7 @@
## 1. High-Level Overview
**Purpose**: build the **model-derived** pre-flight cache artifacts on top of an already-populated tile store, and verify them at takeoff. After C11 `TileDownloader` has fetched tiles into C6, C10 orchestrates: compile/deserialize TensorRT engines via C7 → batch each tile through C2's backbone for descriptors → atomically write FAISS HNSW index with SHA-256 sidecars (D-C10-3) → write Manifest with hash of (model + calibration + corpus + sector_class **+ takeoff_origin**) for D-C10-1 idempotence. The `takeoff_origin` is supplied by C12 (derived from `Flight.waypoints[0]` via the `FlightsApiClient`, ADR-010 + AZ-489); C10 treats it as one more identity field and bakes it into both the Manifest body and the manifest-hash. At F2 takeoff load, run `verify_manifest` (D-C10-3 SHA-256 content-hash gate) before allowing the system to arm; the verifier also surfaces `takeoff_origin` so the companion's composition root can pass it to `C5.set_takeoff_origin(origin, sigma_m)` before any sensor sample (AZ-490).
**Purpose**: build the **model-derived** pre-flight cache artifacts on top of an already-populated tile store, and verify them at takeoff. After C11 `TileDownloader` has fetched tiles into C6, C10 orchestrates: compile/deserialize TensorRT engines via C7 → batch each tile through C2's backbone for descriptors → atomically write FAISS HNSW index with SHA-256 sidecars (D-C10-3) → write Manifest with hash of (model + calibration + corpus + sector_class **+ takeoff_origin**) for D-C10-1 idempotence. The `takeoff_origin` is supplied by C12 (derived from `Flight.waypoints[0]` via the `FlightsApiClient`, ADR-010 + AZ-489); C10 treats it as one more identity field and bakes it into both the Manifest body and the manifest-hash. At F2 takeoff load, run `verify_manifest` (D-C10-3 SHA-256 content-hash gate) before allowing the system to arm; the verifier also surfaces `takeoff_origin` so the companion's composition root can pass it to `C5.set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` before any sensor sample (AZ-490).
**C10 does NOT touch `satellite-provider`.** Tile I/O — both download (F1 inbound) and post-landing upload (F10) — lives in C11 (Tile Manager). C10 reads tiles from C6, writes engines + descriptors + manifest to filesystem and Postgres. The split is operational: C11 carries the operator-side network identity (TLS API key for download, per-flight signing key for upload) and the airborne-exclusion property (ADR-004); C10 carries the model identity and the takeoff-load verifier — neither of which need to leave the workstation/companion enclave at runtime.
@@ -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
+1 -1
View File
@@ -82,7 +82,7 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
**Suite Sat Service** — Synonym for `satellite-provider` used in earlier docs (problem.md, restrictions.md, solution_draft01/02). The actual implementation in the parent suite is the .NET 8 service; "Suite Sat Service" is the role name. (source: `restrictions.md`, parent-suite `satellite-provider/README.md`)
**Takeoff origin**`LatLonAlt` baked into the C10 Manifest by C12 at build time from `Flight.waypoints[0]`. Consumed at boot by C5 via `set_takeoff_origin(origin, sigma_m)` (AZ-490) as a Bayesian prior on the initial pose — iSAM2 attaches a `PriorFactorPose3`; ESKF seeds the nominal position + position-block covariance. Primary cold-start trust anchor per ADR-010; FC EKF GPS is secondary. (source: ADR-010, AZ-490)
**Takeoff origin**`LatLonAlt` baked into the C10 Manifest by C12 at build time from `Flight.waypoints[0]`. Consumed at boot by C5 via `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` (AZ-490) as a Bayesian prior on the initial pose — iSAM2 attaches a `PriorFactorPose3` at `Pose3.Identity()` (origin BECOMES the local-ENU (0,0,0) anchor); ESKF seeds the nominal position to (0,0,0) + position-block covariance to `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`. Primary cold-start trust anchor per ADR-010; FC EKF GPS is secondary. (source: ADR-010, AZ-490)
**Tier-1 / Tier-2** — Testing-environment split: Tier-1 = workstation Docker (fast/cheap); Tier-2 = Jetson hardware (AC-bound). Both appear in the deployment plan and CI matrix per finding F6. (source: `_docs/02_document/tests/environment.md`)
+4 -4
View File
@@ -235,7 +235,7 @@ sequenceDiagram
FC-->>Companion: first telemetry frame
Note over Companion,Pipeline: Cold-start ladder (ADR-010, AZ-490). Operator-origin from Manifest is primary; FC EKF GPS is secondary
alt Manifest carries takeoff_origin (AZ-490 primary path)
Companion->>Pipeline: C5.set_takeoff_origin(manifest.takeoff_origin, sigma_m) BEFORE any add_vio / add_fc_imu
Companion->>Pipeline: C5.set_takeoff_origin(manifest.takeoff_origin, sigma_horiz_m, sigma_vert_m) BEFORE any add_vio / add_fc_imu
else Manifest has no takeoff_origin AND FC EKF GPS is valid (AZ-419 secondary path)
Companion->>FC: query FC EKF last valid GPS + IMU-extrapolated pose (AC-5.1)
FC-->>Companion: warm-start pose
@@ -267,9 +267,9 @@ flowchart TD
SignOk -->|no| RefuseTakeoff
SignOk -->|yes| OriginGate
InavOpen --> OriginGate{Manifest carries takeoff_origin?}
OriginGate -->|yes ADR-010 AZ-490 primary| OperatorOrigin[C5.set_takeoff_origin manifest.takeoff_origin sigma_m]
OriginGate -->|yes ADR-010 AZ-490 primary| OperatorOrigin[C5.set_takeoff_origin manifest.takeoff_origin sigma_horiz_m sigma_vert_m]
OriginGate -->|no| FcEkfGate{FC EKF reports valid non-spoofed GPS?}
FcEkfGate -->|yes AZ-419 secondary| FcOrigin[C5.set_takeoff_origin fc_gps_origin fc_gps_sigma]
FcEkfGate -->|yes AZ-419 secondary| FcOrigin[C5.set_takeoff_origin fc_gps_origin fc_gps_sigma_horiz fc_gps_sigma_vert]
FcEkfGate -->|no| NoOrigin[Stay INITIALIZING and apply FT-P-11 takeoff-abort policy]
OperatorOrigin --> WarmPipeline
FcOrigin --> WarmPipeline
@@ -290,7 +290,7 @@ flowchart TD
| 4 | Companion | C7 / TensorRT | `.engine` deserialize | TensorRT IRuntime |
| 5 | Companion | FC (AP) | signing seed + handshake | MAVLink 2.0 signing |
| 6 | FC | Companion | warm-start pose + IMU/attitude/GPS health | MAVLink (AP) / MSP2 + MAVLink outbound (iNav) |
| 7 | Companion | C5 `StateEstimator` (AZ-490) | `set_takeoff_origin(origin, sigma_m)` with origin = `manifest.takeoff_origin` (primary) OR FC-EKF GPS (secondary) | in-process Protocol method |
| 7 | Companion | C5 `StateEstimator` (AZ-490) | `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` with origin = `manifest.takeoff_origin` (primary) OR FC-EKF GPS (secondary) | in-process Protocol method |
| 8 | Companion | C13 FDR | startup record (config snapshot, signing key rotation event, content-hash digests, chosen cold-start origin source) | FDR record |
### Error scenarios
@@ -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]
```
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step:
phase: 6
name: implement-tasks
detail: "batch 22 of N landed (AZ-489 — C12 FlightsApiClient + offline JSON loader + bbox helper + httpx client). httpx>=0.28,<1.0 added to main deps. 28 unit tests covering AC-1..AC-18 plus extras; full repo 713 passed / 2 skipped. Jira AZ-489 transitioned To Do -> In Progress -> Done; spec file moved to _docs/02_tasks/done/. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. Next: AZ-490 (C5 set_takeoff_origin entrypoint + bounded-delta gate)."
detail: ""
retry_count: 0
cycle: 1
tracker: jira