mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 09:51:14 +00:00
Compare commits
6 Commits
db27e25630
...
daff5d4d1c
| Author | SHA1 | Date | |
|---|---|---|---|
| daff5d4d1c | |||
| f925af9de3 | |||
| 48281db9e9 | |||
| 8a83166261 | |||
| 72a06edab0 | |||
| e0be591b06 |
@@ -39,7 +39,8 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
|
|||||||
8. **D-CROSS-LATENCY-1 hybrid**: K=3 baseline auto-degrades to K=2 + Jacobian covariance under Jetson thermal throttle, preserving AC-4.1 at +50 °C ambient at the cost of ~5–10 % accuracy loss (still inside AC-NEW-4).
|
8. **D-CROSS-LATENCY-1 hybrid**: K=3 baseline auto-degrades to K=2 + Jacobian covariance under Jetson thermal throttle, preserving AC-4.1 at +50 °C ambient at the cost of ~5–10 % accuracy loss (still inside AC-NEW-4).
|
||||||
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.
|
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.
|
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 FC GPS health is stable + non-spoofed for ≥10 s **AND** a visual/satellite consistency check has succeeded (AC-NEW-8).
|
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_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).
|
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.
|
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.
|
||||||
|
|
||||||
@@ -63,17 +64,21 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Companion PC runtime (Jetson Orin Nano Super, JetPack 6.2) | Flight controller firmware (ArduPilot Plane, iNav) |
|
| Companion PC runtime (Jetson Orin Nano Super, JetPack 6.2) | Flight controller firmware (ArduPilot Plane, iNav) |
|
||||||
| All onboard pose-estimation logic (C1–C8, C13) | Parent-suite `satellite-provider` (.NET 8 REST microservice) |
|
| All onboard pose-estimation logic (C1–C8, C13) | Parent-suite `satellite-provider` (.NET 8 REST microservice) |
|
||||||
| Pre-flight cache artifact build (C10 — engines + descriptors + manifest) | GCS (QGroundControl) |
|
| Pre-flight cache artifact build (C10 — engines + descriptors + manifest) | Parent-suite `flights` REST service (.NET 8; owns the `Flight` + `Waypoint` DTOs) |
|
||||||
| Operator-side Tile Manager (C11 — pre-flight download + post-landing upload) | Nav camera hardware (`adti20`); AI-camera hardware |
|
| Operator-side Tile Manager (C11 — pre-flight download + post-landing upload) | Parent-suite Mission Planner UI (`suite/ui` — where operators plan the route) |
|
||||||
| Operator pre-flight tooling (C12) | UAV airframe / FC IMU / sensors |
|
| Operator pre-flight tooling (C12) | GCS (QGroundControl) |
|
||||||
| FDR writer (C13) | Operator's workstation OS / authentication |
|
| FDR writer (C13) | Nav camera hardware (`adti20`); AI-camera hardware |
|
||||||
| Camera calibration artifact format + loader | The act of calibration itself (operator runs checkerboard rig) |
|
| Camera calibration artifact format + loader | UAV airframe / FC IMU / sensors |
|
||||||
|
| | Operator's workstation OS / authentication |
|
||||||
|
| | The act of calibration itself (operator runs checkerboard rig) |
|
||||||
|
|
||||||
**External systems**:
|
**External systems**:
|
||||||
|
|
||||||
| System | Integration Type | Direction | Purpose |
|
| System | Integration Type | Direction | Purpose |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `satellite-provider` (parent-suite .NET 8) | REST + filesystem (read), REST (post-landing write, D-PROJ-2) | Both | Pre-flight tile source; post-landing tile sink (planned) |
|
| `satellite-provider` (parent-suite .NET 8) | REST + filesystem (read), REST (post-landing write, D-PROJ-2) | Both | Pre-flight tile source; post-landing tile sink (planned) |
|
||||||
|
| `flights` REST service (parent-suite .NET 8) | REST (read) over HTTPS | Inbound to C12 | Source of the operator-planned `Flight` (waypoints, ordering, altitudes). C12 derives bbox + takeoff origin from the Flight. **Operator workstation only** — never reached from the airborne companion |
|
||||||
|
| Mission Planner UI (`suite/ui`) | Indirect via `flights` REST | Inbound (mediated) | Where the operator authors the route before C12 consumes it. Out of scope for this project, but the API contract it produces IS in scope |
|
||||||
| ArduPilot Plane FC | MAVLink 2.0 over UART/USB (signed) | Both | Inbound: external position via `GPS_INPUT`. Outbound: IMU, attitude, GPS health, EKF source-set commands |
|
| ArduPilot Plane FC | MAVLink 2.0 over UART/USB (signed) | Both | Inbound: external position via `GPS_INPUT`. Outbound: IMU, attitude, GPS health, EKF source-set commands |
|
||||||
| iNav FC | MSP2 over UART (unsigned), MAVLink outbound | Both | Inbound: external position via `MSP2_SENSOR_GPS` (companion is sole GPS source on iNav). Outbound: IMU/attitude/telemetry |
|
| iNav FC | MSP2 over UART (unsigned), MAVLink outbound | Both | Inbound: external position via `MSP2_SENSOR_GPS` (companion is sole GPS source on iNav). Outbound: IMU/attitude/telemetry |
|
||||||
| QGroundControl (GCS) | MAVLink 2.0 (link-bandwidth-limited) | Both | 1–2 Hz downsampled summary out (AC-6.1); operator commands in (AC-6.2) |
|
| QGroundControl (GCS) | MAVLink 2.0 (link-bandwidth-limited) | Both | 1–2 Hz downsampled summary out (AC-6.1); operator commands in (AC-6.2) |
|
||||||
@@ -197,9 +202,12 @@ source repo
|
|||||||
| `EmittedExternalPosition` | WGS84 + honest `horiz_accuracy` + per-FC encoding (MAVLink `GPS_INPUT` for AP, MSP2 `MSP2_SENSOR_GPS` for iNav) | C8 |
|
| `EmittedExternalPosition` | WGS84 + honest `horiz_accuracy` + per-FC encoding (MAVLink `GPS_INPUT` for AP, MSP2 `MSP2_SENSOR_GPS` for iNav) | C8 |
|
||||||
| `FlightStateSignal` | `IN_AIR | ON_GROUND` boolean derived from FC `MAV_STATE` | C8 inbound side; published to C11 only post-landing |
|
| `FlightStateSignal` | `IN_AIR | ON_GROUND` boolean derived from FC `MAV_STATE` | C8 inbound side; published to C11 only post-landing |
|
||||||
| `FdrRecord` | Estimates + IMU traces + emitted MAVLink + system health + tiles + thumbnails (≤ 64 GB / flight) | C13 |
|
| `FdrRecord` | Estimates + IMU traces + emitted MAVLink + system health + tiles + thumbnails (≤ 64 GB / flight) | C13 |
|
||||||
| `Manifest` | Hash of (model + calibration + corpus + sector classification) for D-C10-1 idempotence | C10 |
|
| `Manifest` | Hash of (model + calibration + corpus + sector classification + takeoff origin) for D-C10-1 idempotence | C10 |
|
||||||
| `EngineCacheEntry` | TRT engine + INT8 calibration cache keyed by SM/JP/TRT/precision tuple (D-C10-7) | C10, C7 |
|
| `EngineCacheEntry` | TRT engine + INT8 calibration cache keyed by SM/JP/TRT/precision tuple (D-C10-7) | C10, C7 |
|
||||||
| `SectorClassification` | `active_conflict | stable_rear` per area, drives freshness threshold | C12 (operator-set) → C6, C10 |
|
| `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_horiz_m, sigma_vert_m)` (AZ-490) | C12 → C10 Manifest → C5 |
|
||||||
|
|
||||||
**Key relationships**:
|
**Key relationships**:
|
||||||
|
|
||||||
@@ -601,3 +609,32 @@ This decision is made on **technical grounds only**. Component licenses (BSD/Apa
|
|||||||
- Build-time exclusion (ADR-002) becomes architectural: the deployment composition root *cannot* `import` a strategy whose file is not part of the deployment binary's CMake target. The same property scales to any future packaging variant — including, if/when product licensing strategy is decided, license-driven bundles (Principle #13 NOTE), without any source-level change in application code.
|
- Build-time exclusion (ADR-002) becomes architectural: the deployment composition root *cannot* `import` a strategy whose file is not part of the deployment binary's CMake target. The same property scales to any future packaging variant — including, if/when product licensing strategy is decided, license-driven bundles (Principle #13 NOTE), without any source-level change in application code.
|
||||||
- Per-component folders give each implementation a natural home for its own `tests/`, fixtures, and adapter-specific helpers — matching coderule.mdc's "logic specific to a platform, variant, or environment belongs in the class that owns that variant".
|
- Per-component folders give each implementation a natural home for its own `tests/`, fixtures, and adapter-specific helpers — matching coderule.mdc's "logic specific to a platform, variant, or environment belongs in the class that owns that variant".
|
||||||
- Adding a new C2 VPR backbone (e.g., a future foundation-model retrieval backbone via D-C2-12) is a folder-add + interface-conformance change; no other component is touched.
|
- Adding a new C2 VPR backbone (e.g., a future foundation-model retrieval backbone via D-C2-12) is a folder-add + interface-conformance change; no other component is touched.
|
||||||
|
|
||||||
|
### ADR-010 — Operator-planned mission is the cold-start trust anchor; FC GPS is secondary
|
||||||
|
|
||||||
|
**Context**: The original cold-start design (AZ-419 / FT-P-11) assumed the FC EKF's last valid GPS fix is available at takeoff to seed C5. Field reality contradicts this: a UAV operating in a contested-EW environment may have GPS jammed **before** takeoff (the jamming radius reaches the launch site, the unit launches under a jammer's umbrella, etc.). In that case the FC EKF has no GPS fix to give, and the companion has nothing to anchor the initial pose to — the entire downstream pipeline (VIO bootstrap, VPR retrieval scope, satellite anchoring) collapses or runs blind. At the same time, the parent suite already requires the operator to author a route in the **Mission Planner UI** (`suite/ui`) and persist it to the **`flights` REST service** (`suite/flights`) before any flight runs. The waypoint ordering is operationally meaningful: waypoint[0] is the planned takeoff point. The operator therefore already declares the takeoff position with operationally relevant accuracy (typically a few tens of metres) hours before launch, in a context that has no dependency on GPS at all. This information is the natural cold-start trust anchor.
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
|
||||||
|
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_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.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
|
||||||
|
1. **Keep FC EKF as primary** (status quo of AZ-419) — rejected: cannot survive GPS-denied takeoff, which is in scope per Principles #1 and #9. Field reports of pre-launch jamming make this a realistic, not edge-case, failure mode.
|
||||||
|
2. **Operator types the origin into a CLI prompt at build-cache time** — rejected: duplicates information the Mission Planner UI already captures, drifts from the canonical route, and breaks if the operator re-plans without re-typing. The `Flight` DTO is the single source of truth.
|
||||||
|
3. **Pull `Flight` from the companion at runtime over a back-channel** — rejected: violates Principle #9 (denied-environment operation; no egress from the companion to anything other than the FC). The flights service is an **operator-workstation** concern only.
|
||||||
|
4. **Treat operator origin as a hard assignment instead of a prior** — rejected: a hard assignment cannot be fused with a later high-quality posterior, breaks ADR-003's "honest covariance" property, and prevents the `add_pose_anchor` fusion path from ever correcting the origin if it was authored with imprecision.
|
||||||
|
|
||||||
|
**Consequences**:
|
||||||
|
|
||||||
|
- 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_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,6 +22,7 @@
|
|||||||
|
|
||||||
| Method | Input | Output | Async | Error Types |
|
| Method | Input | Output | Async | Error Types |
|
||||||
|--------|-------|--------|-------|-------------|
|
|--------|-------|--------|-------|-------------|
|
||||||
|
| `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_vio` | `VioOutput` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
|
||||||
| `add_pose_anchor` | `PoseEstimate` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
|
| `add_pose_anchor` | `PoseEstimate` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
|
||||||
| `add_fc_imu` | `ImuWindow` | `None` | No | `EstimatorDegradedError` |
|
| `add_fc_imu` | `ImuWindow` | `None` | No | `EstimatorDegradedError` |
|
||||||
@@ -76,7 +77,8 @@ C5 is bounded by design — no unbounded growth.
|
|||||||
|
|
||||||
**State Management**:
|
**State Management**:
|
||||||
- iSAM2 graph + Values + Marginals lifecycle for the flight.
|
- iSAM2 graph + Values + Marginals lifecycle for the flight.
|
||||||
- Source-label state machine: tracks the AC-NEW-2 / AC-NEW-8 spoofing-promotion gate (≥10 s + visual consistency check before re-promoting a previously-spoofed FC GPS source).
|
- 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.
|
- Last-anchor-age timer for AC-1.3 binning.
|
||||||
|
|
||||||
**Key Dependencies**:
|
**Key Dependencies**:
|
||||||
@@ -88,9 +90,10 @@ C5 is bounded by design — no unbounded growth.
|
|||||||
| Eigen | matches GTSAM | Lie-algebra math |
|
| Eigen | matches GTSAM | Lie-algebra math |
|
||||||
|
|
||||||
**Error Handling Strategy**:
|
**Error Handling Strategy**:
|
||||||
|
- `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.
|
- `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.
|
- `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: never re-introduce a previously-spoofed FC GPS source until BOTH (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ 10 s AND (ii) the next satellite-anchored frame agrees with the FC GPS within a configurable tolerance (AC-NEW-8). Document every reject in FDR + GCS STATUSTEXT.
|
- 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.
|
||||||
|
|
||||||
## 6. Extensions and Helpers
|
## 6. Extensions and Helpers
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 1. High-Level Overview
|
## 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) for D-C10-1 idempotence. At F2 takeoff load, run `verify_manifest` (D-C10-3 SHA-256 content-hash gate) before allowing the system to arm.
|
**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.
|
**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.
|
||||||
|
|
||||||
@@ -43,6 +43,8 @@ BuildRequest:
|
|||||||
sector_class: enum {active_conflict, stable_rear} # baked into manifest
|
sector_class: enum {active_conflict, stable_rear} # baked into manifest
|
||||||
calibration_path: Path
|
calibration_path: Path
|
||||||
cache_root: Path
|
cache_root: Path
|
||||||
|
takeoff_origin: LatLonAlt | None # ADR-010 / AZ-489; baked into manifest + hash
|
||||||
|
flight_id: UUID | None # ADR-010; pass-through provenance, baked into manifest
|
||||||
|
|
||||||
BuildReport:
|
BuildReport:
|
||||||
engines_built: int
|
engines_built: int
|
||||||
@@ -52,12 +54,14 @@ BuildReport:
|
|||||||
outcome: enum {success, failure, idempotent_no_op}
|
outcome: enum {success, failure, idempotent_no_op}
|
||||||
failure_reason: string (optional)
|
failure_reason: string (optional)
|
||||||
|
|
||||||
Manifest: see data_model.md
|
Manifest: see data_model.md (carries takeoff_origin + flight_id when set; hash includes them)
|
||||||
EngineCacheEntry: see data_model.md
|
EngineCacheEntry: see data_model.md
|
||||||
|
|
||||||
VerificationResult:
|
VerificationResult:
|
||||||
manifest_hash_match: bool
|
manifest_hash_match: bool
|
||||||
per_artifact_hash_match: dict[Path, bool]
|
per_artifact_hash_match: dict[Path, bool]
|
||||||
|
takeoff_origin: LatLonAlt | None # passed through from manifest for C5 warm-start (AZ-490)
|
||||||
|
flight_id: UUID | None
|
||||||
outcome: enum {pass, fail}
|
outcome: enum {pass, fail}
|
||||||
fail_reasons: list[string]
|
fail_reasons: list[string]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
## 1. High-Level Overview
|
## 1. High-Level Overview
|
||||||
|
|
||||||
**Purpose**: operator-facing tooling on the workstation that **sequences** the F1 cache-build workflow (calls C11 `TileDownloader` then C10 `CacheProvisioner`), drives sector classification, freshness pipeline, calibration loading, and (post-flight) triggers the C11 `TileUploader`. Provides the human-in-the-loop entry point for everything that needs operator judgement (active-conflict vs stable-rear sector classification, AC-3.4 ≥3-frame outage operator re-loc requests, network configuration to `satellite-provider`).
|
**Purpose**: operator-facing tooling on the workstation that **sequences** the F1 cache-build workflow (calls `FlightsApiClient` → C11 `TileDownloader` → C10 `CacheProvisioner`), drives sector classification, freshness pipeline, calibration loading, and (post-flight) triggers the C11 `TileUploader`. Provides the human-in-the-loop entry point for everything that needs operator judgement (active-conflict vs stable-rear sector classification, AC-3.4 ≥3-frame outage operator re-loc requests, network configuration to `satellite-provider`).
|
||||||
|
|
||||||
**Architectural Pattern**: Coordinator (single concrete `OperatorTooling` today). Two interfaces: `CacheBuildWorkflow` (pre-flight UX) and `OperatorReLocService` (mid-flight operator re-loc requests, AC-3.4). The latter is consumed via the GCS link by C8 (operator commands subscription).
|
**Architectural Pattern**: Coordinator (single concrete `OperatorTooling` today). Three interfaces: `CacheBuildWorkflow` (pre-flight UX), `FlightsApiClient` (read the operator-authored `Flight` from the parent-suite `flights` REST service or a local JSON export — AZ-489, ADR-010), and `OperatorReLocService` (mid-flight operator re-loc requests, AC-3.4). `OperatorReLocService` is consumed via the GCS link by C8 (operator commands subscription); `FlightsApiClient` is operator-workstation-only and never reaches the airborne companion (Principle #9).
|
||||||
|
|
||||||
**Upstream dependencies**:
|
**Upstream dependencies**:
|
||||||
- Operator (human input).
|
- Operator (human input — flight ID or flight file path, sector classification, calibration path).
|
||||||
- C11 `TileDownloader` (operator-workstation-side) — invoked first in F1 to populate C6 from `satellite-provider`.
|
- `flights` REST service (parent-suite `suite/flights/`) — read via `FlightsApiClient` to fetch the operator-authored `Flight` (waypoints + altitudes); offline alternative is a local JSON export in the same DTO shape.
|
||||||
- C10 CacheProvisioner (companion-side) — invoked second in F1, over USB/Eth.
|
- C11 `TileDownloader` (operator-workstation-side) — invoked second in F1 to populate C6 from `satellite-provider` for the bbox derived from the `Flight`.
|
||||||
|
- C10 CacheProvisioner (companion-side) — invoked third in F1, over USB/Eth, with `takeoff_origin = Flight.waypoints[0]` and the computed bbox.
|
||||||
- Camera calibration artifact (operator workstation filesystem).
|
- Camera calibration artifact (operator workstation filesystem).
|
||||||
|
|
||||||
**Downstream consumers**:
|
**Downstream consumers**:
|
||||||
@@ -24,12 +25,21 @@
|
|||||||
|
|
||||||
| Method | Input | Output | Async | Error Types |
|
| Method | Input | Output | Async | Error Types |
|
||||||
|--------|-------|--------|-------|-------------|
|
|--------|-------|--------|-------|-------------|
|
||||||
| `build_cache` | `bbox, sector_class, calibration_path, satellite_provider_url, api_key` | `CacheBuildReport` (wraps C11 `DownloadBatchReport` + C10 `BuildReport`) | No (operator-facing; minutes) | `CacheBuildError` (wraps SatelliteProviderError, EngineBuildError, etc.) |
|
| `build_cache` | `flight_id` (online) OR `flight_file: Path` (offline), `sector_class`, `calibration_path`, `satellite_provider_url`, `api_key` | `CacheBuildReport` (wraps `FlightResolveReport` + C11 `DownloadBatchReport` + C10 `BuildReport`) | No (operator-facing; minutes) | `CacheBuildError` (wraps `FlightNotFoundError`, `FlightsApiUnreachableError`, `SatelliteProviderError`, `EngineBuildError`, etc.) |
|
||||||
| `trigger_post_landing_upload` | `flight_id` | C11 `UploadBatchReport` | No (operator-facing; minutes) | `CacheBuildError` wrapper around `FlightStateNotOnGroundError`, `SignatureRejectedError`, etc. |
|
| `trigger_post_landing_upload` | `flight_id` | C11 `UploadBatchReport` | No (operator-facing; minutes) | `CacheBuildError` wrapper around `FlightStateNotOnGroundError`, `SignatureRejectedError`, etc. |
|
||||||
| `verify_companion_ready` | `companion_address` | `ReadinessReport` | No | `CompanionUnreachableError`, `ContentHashMismatchError` |
|
| `verify_companion_ready` | `companion_address` | `ReadinessReport` | No | `CompanionUnreachableError`, `ContentHashMismatchError` |
|
||||||
| `set_sector_classification` | `area, sector_class` | `None` | No | — |
|
| `set_sector_classification` | `area, sector_class` | `None` | No | — |
|
||||||
| `apply_freshness_threshold` | `sector_class` | `int (months)` | No | — |
|
| `apply_freshness_threshold` | `sector_class` | `int (months)` | No | — |
|
||||||
|
|
||||||
|
### Interface: `FlightsApiClient` (AZ-489, ADR-010)
|
||||||
|
|
||||||
|
| Method | Input | Output | Async | Error Types |
|
||||||
|
|--------|-------|--------|-------|-------------|
|
||||||
|
| `fetch_flight` | `flight_id: UUID, base_url: str, auth_token: str` | `FlightDto` (with ordered `WaypointDto[]`) | No (HTTPS; seconds) | `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError` |
|
||||||
|
| `load_flight_file` | `path: Path` | `FlightDto` (same shape as `fetch_flight`) | No | `FlightFileNotFoundError`, `FlightsApiSchemaError` |
|
||||||
|
| `bbox_from_waypoints` | `waypoints: list[WaypointDto], buffer_m: float` | `BoundingBox` | No | `EmptyWaypointsError` |
|
||||||
|
| `takeoff_origin_from_flight` | `flight: FlightDto` | `LatLonAlt` (from `waypoints[0]`) | No | `EmptyWaypointsError` |
|
||||||
|
|
||||||
### Interface: `OperatorReLocService`
|
### Interface: `OperatorReLocService`
|
||||||
|
|
||||||
| Method | Input | Output | Async | Error Types |
|
| Method | Input | Output | Async | Error Types |
|
||||||
@@ -38,11 +48,23 @@
|
|||||||
|
|
||||||
**Input/Output DTOs**:
|
**Input/Output DTOs**:
|
||||||
```
|
```
|
||||||
|
FlightDto: mirror of suite/flights/Database/Entities/Flight.cs (id, name, status, waypoints, ...)
|
||||||
|
WaypointDto: mirror of suite/flights/Database/Entities/Waypoint.cs (ordinal, lat, lon, alt, objective, source)
|
||||||
|
|
||||||
|
FlightResolveReport:
|
||||||
|
source: enum {flights_api, flight_file}
|
||||||
|
flight_id: UUID
|
||||||
|
waypoint_count: int
|
||||||
|
bbox: BoundingBox (computed envelope + buffer)
|
||||||
|
takeoff_origin: LatLonAlt (waypoints[0])
|
||||||
|
raw_flight_dto: FlightDto (preserved for FDR + debug)
|
||||||
|
|
||||||
CacheBuildReport:
|
CacheBuildReport:
|
||||||
|
flight_resolve_report: FlightResolveReport
|
||||||
download_report: DownloadBatchReport (see C11 spec)
|
download_report: DownloadBatchReport (see C11 spec)
|
||||||
build_report: BuildReport (see C10 spec)
|
build_report: BuildReport (see C10 spec)
|
||||||
outcome: enum {success, failure, idempotent_no_op}
|
outcome: enum {success, failure, idempotent_no_op}
|
||||||
failure_phase: enum {download, build, none}
|
failure_phase: enum {flight_resolve, download, build, none}
|
||||||
failure_reason: string (optional)
|
failure_reason: string (optional)
|
||||||
|
|
||||||
ReadinessReport:
|
ReadinessReport:
|
||||||
@@ -50,6 +72,7 @@ ReadinessReport:
|
|||||||
content_hashes_pass: bool
|
content_hashes_pass: bool
|
||||||
engines_present: bool
|
engines_present: bool
|
||||||
calibration_present: bool
|
calibration_present: bool
|
||||||
|
takeoff_origin_in_manifest: bool # ADR-010: warn-but-not-fail if absent (FC-EKF fallback path remains)
|
||||||
outcome: enum {ready, not_ready}
|
outcome: enum {ready, not_ready}
|
||||||
not_ready_reasons: list[string]
|
not_ready_reasons: list[string]
|
||||||
|
|
||||||
@@ -63,7 +86,8 @@ ReLocHint:
|
|||||||
|
|
||||||
C12 itself does NOT expose HTTP. It **consumes**:
|
C12 itself does NOT expose HTTP. It **consumes**:
|
||||||
|
|
||||||
- `satellite-provider`'s REST API **only via C11 `TileDownloader`** — C12 itself never holds the TLS / API-key credentials. The credential boundary is C11 (operator-workstation-side); C12 sequences and reports.
|
- `satellite-provider`'s REST API **only via C11 `TileDownloader`** — C12 itself never holds the TLS / API-key credentials for satellite tiles. The credential boundary is C11 (operator-workstation-side); C12 sequences and reports.
|
||||||
|
- The parent-suite `flights` REST service via its own `FlightsApiClient` (AZ-489, ADR-010). C12 holds the TLS + suite credentials for the flights service because the `Flight` shape is a C12-owned concern (bbox + takeoff-origin derivation). The companion never reaches the flights service.
|
||||||
- The GCS link's MAVLink path (operator commands path), for AC-3.4 re-loc requests.
|
- The GCS link's MAVLink path (operator commands path), for AC-3.4 re-loc requests.
|
||||||
|
|
||||||
C12 may eventually expose a thin local HTTP API for an operator GUI. **Plan-phase carryforward, deferred** — the current cycle ships only a CLI.
|
C12 may eventually expose a thin local HTTP API for an operator GUI. **Plan-phase carryforward, deferred** — the current cycle ships only a CLI.
|
||||||
@@ -98,10 +122,14 @@ C12 holds the operator workstation's local cache staging area + per-area sector
|
|||||||
| Click / Typer | per project pin | CLI framework |
|
| Click / Typer | per project pin | CLI framework |
|
||||||
| paramiko / ssh / fabric | per project pin | Companion bring-up over SSH (USB/Eth) |
|
| paramiko / ssh / fabric | per project pin | Companion bring-up over SSH (USB/Eth) |
|
||||||
| pymavlink | bundled per D-C8-3 | GCS-link operator commands (AC-3.4) |
|
| pymavlink | bundled per D-C8-3 | GCS-link operator commands (AC-3.4) |
|
||||||
| (httpx is NOT a direct C12 dep) | — | All `satellite-provider` HTTP traffic is owned by C11; C12 calls C11 in-process |
|
| httpx | per project pin | `FlightsApiClient` HTTPS calls to the parent-suite `flights` REST service (AZ-489) |
|
||||||
|
| pydantic | per project pin | `FlightDto` / `WaypointDto` validation in `FlightsApiClient` (online + offline paths) |
|
||||||
|
| (`satellite-provider` HTTP traffic stays owned by C11) | — | C12 calls C11 in-process |
|
||||||
|
|
||||||
**Error Handling Strategy**:
|
**Error Handling Strategy**:
|
||||||
- `CacheBuildError`: wraps the underlying error from C11/C10/C7/C6 with operator-friendly text + remediation hint. Includes `failure_phase: download | build` so the operator knows which step to retry.
|
- `CacheBuildError`: wraps the underlying error from C11/C10/C7/C6/`FlightsApiClient` with operator-friendly text + remediation hint. Includes `failure_phase: flight_resolve | download | build` so the operator knows which step to retry.
|
||||||
|
- `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`: surfaced by `FlightsApiClient` (online path). Offline path raises `FlightFileNotFoundError` / `FlightsApiSchemaError`. Each maps to an actionable operator hint.
|
||||||
|
- `EmptyWaypointsError`: the resolved `Flight` carries zero waypoints — cannot derive bbox or takeoff origin; refuse build.
|
||||||
- `CompanionUnreachableError`: pre-flight bring-up failure; operator must check the wire/SSH config.
|
- `CompanionUnreachableError`: pre-flight bring-up failure; operator must check the wire/SSH config.
|
||||||
- `ContentHashMismatchError`: post-stage tampering detected; refuse to mark as ready; operator must re-run F1.
|
- `ContentHashMismatchError`: post-stage tampering detected; refuse to mark as ready; operator must re-run F1.
|
||||||
- `GcsLinkError` (AC-3.4 path): GCS link drop. Re-loc request is best-effort; operator may need to re-issue when link recovers.
|
- `GcsLinkError` (AC-3.4 path): GCS link drop. Re-loc request is best-effort; operator may need to re-issue when link recovers.
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class BuildRequest:
|
|||||||
calibration_path: Path
|
calibration_path: Path
|
||||||
cache_root: Path
|
cache_root: Path
|
||||||
key_path: Path # operator signing key per C10-ST-01
|
key_path: Path # operator signing key per C10-ST-01
|
||||||
|
takeoff_origin: LatLonAlt | None = None # ADR-010 + AZ-489: planned takeoff position from Flight.waypoints[0]; baked into Manifest body + build-identity hash
|
||||||
|
flight_id: UUID | None = None # ADR-010: pass-through provenance of which Flight produced the build
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -105,6 +107,8 @@ class BuildReport:
|
|||||||
| CP-INV-5 | `cache_root` must already exist; `build_cache_artifacts` does NOT create the directory tree (operator workflow places it). | Avoids accidental builds in unintended paths. |
|
| CP-INV-5 | `cache_root` must already exist; `build_cache_artifacts` does NOT create the directory tree (operator workflow places it). | Avoids accidental builds in unintended paths. |
|
||||||
| CP-INV-6 | No network calls (no `satellite-provider`, no Postgres TLS to a remote DB beyond the local instance, no metric push). | Epic § Architecture notes: C10 is workstation-local. |
|
| CP-INV-6 | No network calls (no `satellite-provider`, no Postgres TLS to a remote DB beyond the local instance, no metric push). | Epic § Architecture notes: C10 is workstation-local. |
|
||||||
| CP-INV-7 | The operator key file at `request.key_path` is opened exactly once (via AZ-323's signer) and zeroized when out of scope; this contract does NOT cache the key in memory across calls. | Operator key hygiene. |
|
| CP-INV-7 | The operator key file at `request.key_path` is opened exactly once (via AZ-323's signer) and zeroized when out of scope; this contract does NOT cache the key in memory across calls. | Operator key hygiene. |
|
||||||
|
| CP-INV-8 | `takeoff_origin` is treated as one more identity field by the build-identity hash. If the prior Manifest carries `takeoff_origin=A` and a new request carries `takeoff_origin=B != A` (with all other fields equal), the build is NOT idempotent and proceeds; the verifier (AZ-324) at boot then refuses any cache whose manifest origin disagrees with the manifest-on-disk's origin. | ADR-010: cache identity must include the origin or boot-time consistency breaks. |
|
||||||
|
| CP-INV-9 | When `takeoff_origin` is None, the prior cold-start ladder (FC-EKF-GPS via AZ-419) remains the only origin source. C10 does not invent a default origin from the bbox; that decision is for C12. | Single-responsibility — C10 records, C12 decides. |
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
@@ -125,6 +129,7 @@ class BuildReport:
|
|||||||
| Version | Date | Notes | Author |
|
| Version | Date | Notes | Author |
|
||||||
|---------|------|-------|--------|
|
|---------|------|-------|--------|
|
||||||
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-325 (E-C10 decomposition) | autodev |
|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-325 (E-C10 decomposition) | autodev |
|
||||||
|
| 1.1.0 | 2026-05-11 | Additive: `BuildRequest.takeoff_origin` + `BuildRequest.flight_id` (defaults `None` for back-compat); CP-INV-8 + CP-INV-9. Consumer requires the Manifest hash to include `takeoff_origin` when set. ADR-010 + AZ-489. | autodev |
|
||||||
|
|
||||||
## Test Cases (consumer side)
|
## Test Cases (consumer side)
|
||||||
|
|
||||||
@@ -143,3 +148,6 @@ class BuildReport:
|
|||||||
| CP-TC-11 | `compile_engines_for_corpus` directly callable for re-compile-only flows | Returns `tuple[EngineCacheEntry, ...]`; no descriptor / Manifest work |
|
| CP-TC-11 | `compile_engines_for_corpus` directly callable for re-compile-only flows | Returns `tuple[EngineCacheEntry, ...]`; no descriptor / Manifest work |
|
||||||
| CP-TC-12 | Cold build wall-clock benchmark on Tier-1 dev workstation, 1k tiles, 3 backbones | ≤ 12 min (NFR C10-PT-01) |
|
| CP-TC-12 | Cold build wall-clock benchmark on Tier-1 dev workstation, 1k tiles, 3 backbones | ≤ 12 min (NFR C10-PT-01) |
|
||||||
| CP-TC-13 | Warm idempotent re-run benchmark | ≤ 1 min (NFR C10-PT-01) |
|
| CP-TC-13 | Warm idempotent re-run benchmark | ≤ 1 min (NFR C10-PT-01) |
|
||||||
|
| CP-TC-14 | Build with `takeoff_origin=A` → second build with same request + `takeoff_origin=A` | `outcome=IDEMPOTENT_NO_OP` |
|
||||||
|
| CP-TC-15 | Build with `takeoff_origin=A` → second build with same request + `takeoff_origin=B (B != A)` | `outcome=SUCCESS` (re-build); new Manifest hash differs from prior |
|
||||||
|
| CP-TC-16 | `BuildRequest.takeoff_origin=None` with no prior Manifest | `outcome=SUCCESS`; Manifest written without `takeoff_origin` field |
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ class VerifyFailReason(Enum):
|
|||||||
ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch"
|
ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch"
|
||||||
TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch"
|
TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch"
|
||||||
MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch"
|
MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch"
|
||||||
|
TAKEOFF_ORIGIN_INVALID = "takeoff_origin_invalid" # ADR-010: schema check on the LatLonAlt block
|
||||||
|
TAKEOFF_ORIGIN_OUT_OF_BBOX = "takeoff_origin_out_of_bbox" # ADR-010: origin must lie inside the cache bbox
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -79,6 +81,8 @@ class VerificationResult:
|
|||||||
fail_details: tuple[str, ...] # human-readable diagnostic per reason
|
fail_details: tuple[str, ...] # human-readable diagnostic per reason
|
||||||
signing_public_key_fingerprint: str | None # populated when signature parses, even if untrusted
|
signing_public_key_fingerprint: str | None # populated when signature parses, even if untrusted
|
||||||
per_artifact_checks: tuple[ArtifactCheck, ...]
|
per_artifact_checks: tuple[ArtifactCheck, ...]
|
||||||
|
takeoff_origin: LatLonAlt | None # ADR-010 + AZ-490: passed through from Manifest body; None when Manifest carries no origin
|
||||||
|
flight_id: UUID | None # ADR-010: provenance of which Flight produced the build
|
||||||
elapsed_ms: int
|
elapsed_ms: int
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -93,6 +97,8 @@ class VerificationResult:
|
|||||||
| MV-INV-5 | `tiles_coverage` mismatch is reported separately from `ARTIFACT_HASH_MISMATCH` because tiles are hashed in aggregate (per AZ-323). The verifier re-derives the aggregate hash from a `TileMetadataStore` query if available, OR (in airborne F2 mode) treats the recorded `tiles_coverage_sha256` as authoritative and only verifies the Manifest signature + non-tile artifacts. | Airborne C5 may not load 100k per-tile rows just to arm; the trust chain is signature → manifest_hash → tiles_coverage_sha256. C12 / operator mode does the full re-derivation. |
|
| MV-INV-5 | `tiles_coverage` mismatch is reported separately from `ARTIFACT_HASH_MISMATCH` because tiles are hashed in aggregate (per AZ-323). The verifier re-derives the aggregate hash from a `TileMetadataStore` query if available, OR (in airborne F2 mode) treats the recorded `tiles_coverage_sha256` as authoritative and only verifies the Manifest signature + non-tile artifacts. | Airborne C5 may not load 100k per-tile rows just to arm; the trust chain is signature → manifest_hash → tiles_coverage_sha256. C12 / operator mode does the full re-derivation. |
|
||||||
| MV-INV-6 | The verifier never writes to disk, never opens network sockets, never calls C13. Telemetry is the caller's responsibility. | Read-only contract — composable in airborne C5 + operator C12 contexts without side-effect surprise. |
|
| MV-INV-6 | The verifier never writes to disk, never opens network sockets, never calls C13. Telemetry is the caller's responsibility. | Read-only contract — composable in airborne C5 + operator C12 contexts without side-effect surprise. |
|
||||||
| MV-INV-7 | `elapsed_ms` is recorded for every call (pass or fail) so operators and C5 can observe drift in verify cost on slow disks. | NFR for C10-PT-01's takeoff load budget. |
|
| MV-INV-7 | `elapsed_ms` is recorded for every call (pass or fail) so operators and C5 can observe drift in verify cost on slow disks. | NFR for C10-PT-01's takeoff load budget. |
|
||||||
|
| MV-INV-8 | When the Manifest body carries a `takeoff_origin`, the verifier checks: (a) the LatLonAlt block is well-formed (`-90 ≤ lat ≤ 90`, `-180 ≤ lon ≤ 180`, `alt` finite) → `TAKEOFF_ORIGIN_INVALID` otherwise, (b) the lat/lon falls inside the Manifest's `bbox` → `TAKEOFF_ORIGIN_OUT_OF_BBOX` otherwise. When the Manifest body carries no `takeoff_origin`, the field is absent from `VerificationResult` (None) and no origin check runs. | ADR-010: garbage / out-of-bbox origin must not silently propagate to `C5.set_takeoff_origin`. |
|
||||||
|
| MV-INV-9 | `takeoff_origin` is surfaced on `VerificationResult` even on `FAIL` outcomes when the Manifest body parsed (so caller can inspect what was attempted), but the takeoff-arming gate only consumes it on `PASS`. | Diagnostics — operators can see "your origin was X and that's why we rejected it". |
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
@@ -112,6 +118,7 @@ class VerificationResult:
|
|||||||
| Version | Date | Notes | Author |
|
| Version | Date | Notes | Author |
|
||||||
|---------|------|-------|--------|
|
|---------|------|-------|--------|
|
||||||
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-324 (E-C10 decomposition) | autodev |
|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-324 (E-C10 decomposition) | autodev |
|
||||||
|
| 1.1.0 | 2026-05-11 | Additive: `VerificationResult.takeoff_origin` + `flight_id`; new `VerifyFailReason.TAKEOFF_ORIGIN_INVALID` + `TAKEOFF_ORIGIN_OUT_OF_BBOX`; MV-INV-8 + MV-INV-9. ADR-010 + AZ-490. | autodev |
|
||||||
|
|
||||||
## Test Cases (consumer side)
|
## Test Cases (consumer side)
|
||||||
|
|
||||||
@@ -132,3 +139,7 @@ class VerificationResult:
|
|||||||
| MV-TC-13 | Tier-2 Tile-coverage check (operator mode with TileMetadataStore) | If recomputed `tiles_coverage_sha256` differs → `TILES_COVERAGE_MISMATCH`; if matches → that part passes |
|
| MV-TC-13 | Tier-2 Tile-coverage check (operator mode with TileMetadataStore) | If recomputed `tiles_coverage_sha256` differs → `TILES_COVERAGE_MISMATCH`; if matches → that part passes |
|
||||||
| MV-TC-14 | Empty `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` (every key is untrusted by definition) |
|
| MV-TC-14 | Empty `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` (every key is untrusted by definition) |
|
||||||
| MV-TC-15 | Pristine Manifest verified inside 100 ms on Tier-2 (excludes per-tile re-walk) | `elapsed_ms ≤ 100` for the signature + non-tile artifact path |
|
| MV-TC-15 | Pristine Manifest verified inside 100 ms on Tier-2 (excludes per-tile re-walk) | `elapsed_ms ≤ 100` for the signature + non-tile artifact path |
|
||||||
|
| MV-TC-16 | Manifest body carries no `takeoff_origin` | `outcome=PASS`; `VerificationResult.takeoff_origin is None` |
|
||||||
|
| MV-TC-17 | Manifest body carries a well-formed `takeoff_origin` inside bbox | `outcome=PASS`; `VerificationResult.takeoff_origin` populated as `LatLonAlt` |
|
||||||
|
| MV-TC-18 | Manifest body carries a malformed `takeoff_origin` (lat = 200) | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_INVALID`; `takeoff_origin` field is populated for diagnostics |
|
||||||
|
| MV-TC-19 | Manifest body carries `takeoff_origin` outside the recorded `bbox` | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_OUT_OF_BBOX` |
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Contract: flights_api_client
|
||||||
|
|
||||||
|
**Component**: c12_operator_tooling
|
||||||
|
**Producer task**: AZ-489 — `_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md`
|
||||||
|
**Consumer tasks**: AZ-326 (CLI app — wires `--flight-id` / `--flight-file` flags), AZ-328 (build-cache orchestrator — calls `fetch_flight` / `load_flight_file`, then `bbox_from_waypoints` + `takeoff_origin_from_flight`)
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Status**: draft
|
||||||
|
**Last Updated**: 2026-05-11
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines the operator-workstation ↔ parent-suite `flights` REST service boundary plus an offline fallback for the same DTO shape. C12 consumes a typed `FlightDto` (waypoints + altitudes) to derive the cache bbox and the takeoff origin per ADR-010. The companion never sees this contract; it lives entirely on the operator workstation.
|
||||||
|
|
||||||
|
The boundary is split into two sources that produce the **same DTO shape**:
|
||||||
|
|
||||||
|
- **Online**: `GET /flights/{id}` + `GET /flights/{id}/waypoints` against the parent-suite `flights` REST service.
|
||||||
|
- **Offline**: a local JSON export in the same DTO shape (for operators without a workstation-to-flights path, e.g., field-deployed laptops).
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
### DTOs
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
from uuid import UUID
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import LatLonAlt, BoundingBox
|
||||||
|
|
||||||
|
|
||||||
|
class WaypointObjective(Enum):
|
||||||
|
TAKEOFF = "takeoff"
|
||||||
|
WAYPOINT = "waypoint"
|
||||||
|
LOITER = "loiter"
|
||||||
|
LANDING = "landing"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class WaypointSource(Enum):
|
||||||
|
OPERATOR = "operator"
|
||||||
|
IMPORT = "import"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WaypointDto:
|
||||||
|
ordinal: int # >= 0; defines the order of the waypoint inside the Flight
|
||||||
|
lat_deg: float # -90 <= lat <= 90
|
||||||
|
lon_deg: float # -180 <= lon <= 180
|
||||||
|
alt_m: float # WGS84 ellipsoidal height; finite
|
||||||
|
objective: WaypointObjective
|
||||||
|
source: WaypointSource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FlightDto:
|
||||||
|
flight_id: UUID
|
||||||
|
name: str
|
||||||
|
waypoints: tuple[WaypointDto, ...] # ORDERED by ordinal ascending; non-empty
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
```python
|
||||||
|
@runtime_checkable
|
||||||
|
class FlightsApiClient(Protocol):
|
||||||
|
"""Read a Flight from the parent-suite flights REST service or a local JSON export.
|
||||||
|
|
||||||
|
Pure read; no side effects beyond logging. Caller (C12 CacheBuildWorkflow)
|
||||||
|
decides which source to use based on CLI flags (--flight-id vs --flight-file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fetch_flight(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
flight_id: UUID,
|
||||||
|
base_url: str,
|
||||||
|
auth_token: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> FlightDto: ...
|
||||||
|
|
||||||
|
def load_flight_file(self, *, path: Path) -> FlightDto: ...
|
||||||
|
|
||||||
|
def bbox_from_waypoints(
|
||||||
|
self,
|
||||||
|
waypoints: tuple[WaypointDto, ...],
|
||||||
|
*,
|
||||||
|
buffer_m: float = 1000.0,
|
||||||
|
) -> BoundingBox: ...
|
||||||
|
|
||||||
|
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exceptions
|
||||||
|
|
||||||
|
| Exception | When raised | Caller action |
|
||||||
|
|-----------|-------------|---------------|
|
||||||
|
| `FlightsApiUnreachableError` | HTTPS timeout / connection refused / 5xx | Operator retries online when network recovers OR switches to `--flight-file` offline path |
|
||||||
|
| `FlightsApiAuthError` | HTTP 401 / 403 | Operator refreshes suite credentials; never silently fall back to offline |
|
||||||
|
| `FlightNotFoundError` | HTTP 404 for the given `flight_id` | Operator verifies the GUID in the Mission Planner UI |
|
||||||
|
| `FlightsApiSchemaError` | Response body fails `FlightDto` validation (online) OR JSON file fails the same validation (offline) | Operator re-exports from the Mission Planner UI; bug in the schema map is a release-blocking defect |
|
||||||
|
| `FlightFileNotFoundError` | `path` does not exist (offline) | Operator confirms the path |
|
||||||
|
| `EmptyWaypointsError` | Resolved `FlightDto` carries zero waypoints — `bbox_from_waypoints` / `takeoff_origin_from_flight` cannot proceed | Operator re-plans in Mission Planner UI |
|
||||||
|
| `WaypointSchemaError` | Individual `WaypointDto` is malformed (lat out of range, NaN alt, negative ordinal, gap in ordering) | Operator re-exports / re-plans |
|
||||||
|
|
||||||
|
The Protocol does NOT catch these inside the methods — it raises and lets `CacheBuildWorkflow` translate to `CacheBuildError` with the appropriate `failure_phase=flight_resolve`.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
| ID | Invariant | Why |
|
||||||
|
|----|-----------|-----|
|
||||||
|
| FAC-INV-1 | The online (`fetch_flight`) and offline (`load_flight_file`) paths return DTOs with **the same shape and the same validation contract**. The only difference is where the bytes come from. | Caller can swap paths without conditional handling. |
|
||||||
|
| FAC-INV-2 | `FlightDto.waypoints` is non-empty and is ordered by ascending `ordinal`. Implementations MUST sort + validate the ordering. | `waypoints[0]` is the takeoff origin per ADR-010; ordering is operationally meaningful. |
|
||||||
|
| FAC-INV-3 | `bbox_from_waypoints` envelopes the lat/lon of every waypoint and inflates by the `buffer_m` parameter (default 1 km). The buffer is a horizontal-distance expansion, not a degree-space expansion — implementations use `WgsConverter` to inflate correctly at the Flight's latitude. | A degree-space buffer would be 1.5× too narrow at high latitudes and miss tiles near the poles. |
|
||||||
|
| FAC-INV-4 | `takeoff_origin_from_flight` returns `LatLonAlt(waypoints[0].lat_deg, waypoints[0].lon_deg, waypoints[0].alt_m)` — no rounding, no projection. | The operator authored this point; we pass it through. |
|
||||||
|
| FAC-INV-5 | `fetch_flight` issues at most ONE retry on transient 5xx (with 1 s backoff) and at most ONE retry on connection error. 401/403/404/schema failures are NOT retried. | Operator-visible failures should be loud; transient blips should not require operator intervention. |
|
||||||
|
| FAC-INV-6 | `fetch_flight` is the ONLY method that makes network calls. `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight` are pure / filesystem-only. | Composability — the offline path is fully usable on an air-gapped operator workstation. |
|
||||||
|
| FAC-INV-7 | `auth_token` is never logged; structured logs redact the field. | Operator credential hygiene. |
|
||||||
|
| FAC-INV-8 | No write methods. This client is strictly read-only against the flights service. | Single responsibility — C12 does not author flights. |
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Flight authoring / editing — the Mission Planner UI (`suite/ui`) owns that.
|
||||||
|
- Live updates / websockets — pre-flight only.
|
||||||
|
- Caching the `FlightDto` across runs — every `build-cache` invocation re-fetches OR re-reads the file.
|
||||||
|
- Posting build status back to the `flights` REST service — out of scope this cycle.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- v1.0.0 — initial Protocol surface (this document).
|
||||||
|
- Breaking changes (changing DTO shape, removing a method) — bump major.
|
||||||
|
- Additive changes (new optional kwarg, new enum value) — bump minor. Consumers MUST handle unknown enum values gracefully.
|
||||||
|
- Patch — clarifications, doc edits.
|
||||||
|
|
||||||
|
| Version | Date | Notes | Author |
|
||||||
|
|---------|------|-------|--------|
|
||||||
|
| 1.0.0 | 2026-05-11 | Initial contract — produced by AZ-489 (ADR-010 cold-start origin via operator-planned mission) | autodev |
|
||||||
|
|
||||||
|
## Test Cases (consumer side)
|
||||||
|
|
||||||
|
| ID | Scenario | Expected Outcome |
|
||||||
|
|----|----------|------------------|
|
||||||
|
| FAC-TC-1 | Online happy path: valid `flight_id`, reachable service, 3 waypoints | Returns `FlightDto` with 3 ordered waypoints |
|
||||||
|
| FAC-TC-2 | Online 404 (unknown flight_id) | Raises `FlightNotFoundError`; does NOT retry |
|
||||||
|
| FAC-TC-3 | Online 401 (bad auth_token) | Raises `FlightsApiAuthError`; does NOT retry; does NOT log token |
|
||||||
|
| FAC-TC-4 | Online 503 transient | Retries once with 1 s backoff; succeeds; returns DTO |
|
||||||
|
| FAC-TC-5 | Online 503 persistent | Raises `FlightsApiUnreachableError` after one retry |
|
||||||
|
| FAC-TC-6 | Online connection refused | Retries once; raises `FlightsApiUnreachableError` after one retry |
|
||||||
|
| FAC-TC-7 | Online schema drift (response missing `lat`) | Raises `FlightsApiSchemaError` with field reference |
|
||||||
|
| FAC-TC-8 | Offline happy path: well-formed JSON | Returns equivalent `FlightDto` |
|
||||||
|
| FAC-TC-9 | Offline file missing | Raises `FlightFileNotFoundError` |
|
||||||
|
| FAC-TC-10 | Offline JSON missing waypoints array | Raises `FlightsApiSchemaError` |
|
||||||
|
| FAC-TC-11 | Empty waypoints | `fetch_flight` / `load_flight_file` accept but downstream `bbox_from_waypoints` raises `EmptyWaypointsError` |
|
||||||
|
| FAC-TC-12 | Waypoint with `lat=200` | Raises `WaypointSchemaError` during DTO validation |
|
||||||
|
| FAC-TC-13 | Waypoints out of ordinal order | Implementation sorts + validates; returns `FlightDto` with sorted tuple |
|
||||||
|
| FAC-TC-14 | Waypoints with ordinal gap (0, 1, 3) | Raises `WaypointSchemaError` (gap implies missing waypoint, not robust to silent reorder) |
|
||||||
|
| FAC-TC-15 | `bbox_from_waypoints(buffer_m=1000)` at mid-latitudes (~50°N) | Returned bbox extends ~1 km horizontally on all sides, NOT 1° in degree space |
|
||||||
|
| FAC-TC-16 | `takeoff_origin_from_flight(flight)` with `waypoints[0] = (50.0, 36.2, 200.0)` | Returns `LatLonAlt(50.0, 36.2, 200.0)` exactly |
|
||||||
|
| FAC-TC-17 | Conformance: `isinstance(impl, FlightsApiClient)` | `True` |
|
||||||
|
| FAC-TC-18 | Online + Offline produce byte-identical `FlightDto` for the same source | `assert online_dto == offline_dto` |
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**Owner**: c5_state (epic AZ-260 / E-C5)
|
**Owner**: c5_state (epic AZ-260 / E-C5)
|
||||||
**Producer task**: AZ-381 (Protocol + DTOs + factory + composition + concrete `ISam2GraphHandle`)
|
**Producer task**: AZ-381 (Protocol + DTOs + factory + composition + concrete `ISam2GraphHandle`)
|
||||||
**Consumer tasks**: AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring), AZ-383 (Factor adds), AZ-384 (Marginals + outputs), AZ-385 (Source-label + spoof gate), AZ-386 (ESKF baseline), AZ-387 (Smoothed history → FDR), AZ-388 (AC-5.2 fallback), AZ-389 (Orthorectifier → C6).
|
**Consumer tasks**: AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring), AZ-383 (Factor adds), AZ-384 (Marginals + outputs), AZ-385 (Source-label + spoof gate), AZ-386 (ESKF baseline), AZ-387 (Smoothed history → FDR), AZ-388 (AC-5.2 fallback), AZ-389 (Orthorectifier → C6), AZ-490 (set_takeoff_origin — operator-provided warm-start).
|
||||||
**Version**: 1.0.0
|
**Version**: 1.1.0
|
||||||
**Status**: active
|
**Status**: active
|
||||||
**Last Updated**: 2026-05-11
|
**Last Updated**: 2026-05-11
|
||||||
**Module-layout home**: `src/gps_denied_onboard/components/c5_state/interface.py`, `src/gps_denied_onboard/components/c5_state/__init__.py`, `src/gps_denied_onboard/runtime_root/state_factory.py`
|
**Module-layout home**: `src/gps_denied_onboard/components/c5_state/interface.py`, `src/gps_denied_onboard/components/c5_state/__init__.py`, `src/gps_denied_onboard/runtime_root/state_factory.py`
|
||||||
@@ -23,6 +23,14 @@ The shared `ImuPreintegrator` (AZ-276), `SE3Utils` (AZ-277), and `WgsConverter`
|
|||||||
```python
|
```python
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class StateEstimator(Protocol):
|
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_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
def add_vio(self, vio: VioOutput) -> None: ...
|
def add_vio(self, vio: VioOutput) -> None: ...
|
||||||
def add_pose_anchor(self, pose: PoseEstimate) -> None: ...
|
def add_pose_anchor(self, pose: PoseEstimate) -> None: ...
|
||||||
def add_fc_imu(self, imu_window: ImuWindow) -> None: ...
|
def add_fc_imu(self, imu_window: ImuWindow) -> None: ...
|
||||||
@@ -43,6 +51,8 @@ class StateEstimator(Protocol):
|
|||||||
8. **Spoof-rejection events ALWAYS land in FDR + GCS STATUSTEXT** — never silent (R07; C5-ST-01).
|
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.
|
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`.
|
10. **`covariance_6x6` is always SPD** — both strategies enforce; on numerical failure raise `EstimatorFatalError`.
|
||||||
|
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`)
|
### DTOs (in `_types/state.py`)
|
||||||
|
|
||||||
@@ -111,7 +121,10 @@ Config schema additions:
|
|||||||
- `config.state.keyframe_window_size` (int, default 15) — D-C5-3 K=10–20
|
- `config.state.keyframe_window_size` (int, default 15) — D-C5-3 K=10–20
|
||||||
- `config.state.spoof_promotion_min_stable_s` (float, default 10.0) — AC-NEW-2
|
- `config.state.spoof_promotion_min_stable_s` (float, default 10.0) — AC-NEW-2
|
||||||
- `config.state.spoof_promotion_visual_consistency_tol_m` (float, default 30.0) — AC-NEW-8
|
- `config.state.spoof_promotion_visual_consistency_tol_m` (float, default 30.0) — AC-NEW-8
|
||||||
|
- `config.state.spoof_promotion_bounded_delta_m` (float, default 200.0) — Principle #11 amended, ADR-010
|
||||||
- `config.state.no_estimate_fallback_s` (float, default 3.0) — AC-5.2
|
- `config.state.no_estimate_fallback_s` (float, default 3.0) — AC-5.2
|
||||||
|
- `config.state.default_takeoff_origin_sigma_horiz_m` (float, default 50.0) — AZ-490 default horizontal sigma when caller omits
|
||||||
|
- `config.state.default_takeoff_origin_sigma_vert_m` (float, default 100.0) — AZ-490 default vertical sigma when caller omits
|
||||||
|
|
||||||
## Test expectations summarised by Invariant
|
## Test expectations summarised by Invariant
|
||||||
|
|
||||||
@@ -127,6 +140,12 @@ Config schema additions:
|
|||||||
| 8 | Spoof-rejection logging | FDR + GCS STATUSTEXT both fire on every gate decision |
|
| 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 |
|
| 9 | AC-5.2 timeout | 3 s no estimate → fallback signal emitted |
|
||||||
| 10 | SPD covariance | every emitted `covariance_6x6` is SPD |
|
| 10 | SPD covariance | every emitted `covariance_6x6` is SPD |
|
||||||
|
| 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
|
## Producer-task / consumer-task split
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
|
|||||||
|
|
||||||
**FDR / Flight Data Recorder** — Per-flight onboard NVM record (≤64 GB) of estimates, IMU traces, MAVLink stream, mid-flight tiles, system health, failed-tile thumbnails. Excludes raw nav/AI-camera frames. (source: AC-NEW-3)
|
**FDR / Flight Data Recorder** — Per-flight onboard NVM record (≤64 GB) of estimates, IMU traces, MAVLink stream, mid-flight tiles, system health, failed-tile thumbnails. Excludes raw nav/AI-camera frames. (source: AC-NEW-3)
|
||||||
|
|
||||||
|
**Flight** — Operator-authored mission persisted in the parent-suite `flights` REST service. Carries an ordered list of `Waypoint` entries (lat / lon / alt / objective / source). The DTO shape mirrors `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. C12 reads the `Flight` pre-flight to derive the cache bbox + takeoff origin (AZ-489); the companion never reaches the flights service. (source: ADR-010, AZ-489)
|
||||||
|
|
||||||
|
**Flights API / `flights` REST service** — Parent-suite .NET 8 REST microservice (`suite/flights/`) that owns `Flight` + `Waypoint` persistence. Read pre-flight by C12 `FlightsApiClient` over HTTPS. Operator-workstation-only — never reached from the airborne companion (Principle #9). (source: ADR-010, AZ-489)
|
||||||
|
|
||||||
**Flight state** — Boolean signal `IN_AIR | ON_GROUND` derived from FC `MAV_STATE` (MAVLink HEARTBEAT). Safety-critical: gates the post-landing upload path; `IN_AIR` forbids any outbound write to `satellite-provider`. Enforced primarily by process-level isolation — the Tile Manager (C11), which carries both the `TileDownloader` and the `TileUploader`, is not loaded in the airborne companion image. (source: user directive 2026-05-09)
|
**Flight state** — Boolean signal `IN_AIR | ON_GROUND` derived from FC `MAV_STATE` (MAVLink HEARTBEAT). Safety-critical: gates the post-landing upload path; `IN_AIR` forbids any outbound write to `satellite-provider`. Enforced primarily by process-level isolation — the Tile Manager (C11), which carries both the `TileDownloader` and the `TileUploader`, is not loaded in the airborne companion image. (source: user directive 2026-05-09)
|
||||||
|
|
||||||
**GCS / Ground Control Station** — QGroundControl. Mission Planner is out of scope. (source: `restrictions.md`)
|
**GCS / Ground Control Station** — QGroundControl. Mission Planner is out of scope. (source: `restrictions.md`)
|
||||||
@@ -52,15 +56,19 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
|
|||||||
|
|
||||||
**Jetson Orin Nano Super** — Pinned companion compute: 67 TOPS sparse INT8, 8 GB shared LPDDR5, 25 W TDP, JetPack/CUDA/TensorRT. (source: `restrictions.md`)
|
**Jetson Orin Nano Super** — Pinned companion compute: 67 TOPS sparse INT8, 8 GB shared LPDDR5, 25 W TDP, JetPack/CUDA/TensorRT. (source: `restrictions.md`)
|
||||||
|
|
||||||
|
**Mid-flight bounded-delta GPS gate** — Third clause of Principle #11. Even when FC GPS health is "stable + non-spoofed for ≥ 10 s" and the visual/satellite consistency check has succeeded, the FC's reported position must be within ≤ 200 m (configurable) of the companion's last emitted `PoseEstimate` before the FC GPS is fused via `add_pose_anchor`. Catches "FC reports stable GPS but the value is wrong". (source: Principle #11 amended, ADR-010)
|
||||||
|
|
||||||
**Mid-flight tile generation** — Companion orthorectifies nav-camera frames into basemap-projected tiles in flight, deduplicates, stores locally in `satellite-provider`-compatible format. NO outbound upload while airborne — upload happens post-landing only. (source: AC-8.4, user directive 2026-05-09)
|
**Mid-flight tile generation** — Companion orthorectifies nav-camera frames into basemap-projected tiles in flight, deduplicates, stores locally in `satellite-provider`-compatible format. NO outbound upload while airborne — upload happens post-landing only. (source: AC-8.4, user directive 2026-05-09)
|
||||||
|
|
||||||
|
**Mission Planner UI** — Parent-suite operator-facing web UI at `suite/ui/` where operators author flight routes (waypoints + altitudes + objectives) before C12 cache provisioning. Persists routes to the `flights` REST service. Out of scope for this project's deliverables, but the `Flight` DTO it produces IS in scope as an inbound boundary. Not to be confused with the GCS-side "Mission Planner" desktop tool — that is out of scope (only QGroundControl is the supported GCS). (source: ADR-010)
|
||||||
|
|
||||||
**Mission profile** — 8 h flight, ~150 km² operational sector + ~50 km² transit corridor, ≤400 km² total cached, ~60 km/h cruise, ≤1 km AGL, eastern/southern Ukraine. (source: `restrictions.md`)
|
**Mission profile** — 8 h flight, ~150 km² operational sector + ~50 km² transit corridor, ≤400 km² total cached, ~60 km/h cruise, ≤1 km AGL, eastern/southern Ukraine. (source: `restrictions.md`)
|
||||||
|
|
||||||
**`MSP2_SENSOR_GPS`** — MSP2 message used as the per-frame FC delivery channel for iNav (iNav has no inbound MAVLink external-positioning handler). (source: `restrictions.md`, AC-4.3)
|
**`MSP2_SENSOR_GPS`** — MSP2 message used as the per-frame FC delivery channel for iNav (iNav has no inbound MAVLink external-positioning handler). (source: `restrictions.md`, AC-4.3)
|
||||||
|
|
||||||
**Nav camera / Navigation camera** — The fixed-downward (no gimbal) camera on the UAV; pinned model is `adti20`. Distinct from the operator-controlled AI camera. (source: `restrictions.md` §Cameras)
|
**Nav camera / Navigation camera** — The fixed-downward (no gimbal) camera on the UAV; pinned model is `adti20`. Distinct from the operator-controlled AI camera. (source: `restrictions.md` §Cameras)
|
||||||
|
|
||||||
**Operator** — Pre-flight and post-flight human role: classifies the operational area (active-conflict vs stable rear), drives the **Tile Manager** to download tiles from `satellite-provider`, stages calibration onto the companion before takeoff, and after landing triggers the **Tile Manager** upload run. (source: `problem.md`, AC-3.4 / AC-6.2, user confirmation 2026-05-09)
|
**Operator** — Pre-flight and post-flight human role: authors the flight route in the **Mission Planner UI** (`suite/ui`), classifies the operational area (active-conflict vs stable rear), drives C12 cache provisioning (which reads the `Flight` from the parent-suite `flights` REST service, downloads satellite tiles via the **Tile Manager** for the route bbox, and bakes the takeoff origin into the C10 Manifest), stages calibration onto the companion before takeoff, and after landing triggers the **Tile Manager** upload run. (source: `problem.md`, AC-3.4 / AC-6.2, ADR-010, user confirmation 2026-05-09 + 2026-05-11)
|
||||||
|
|
||||||
**Tile Manager** — Operator-side component (C11) that owns both directions of network I/O against `satellite-provider`: pre-flight download (F1) into the local C6 store via the `TileDownloader` interface, and post-landing upload (F10) from C6 to the parent-suite ingest endpoint via the `TileUploader` interface (gated on `flight state == ON_GROUND`). Implemented as a separate binary / image so neither network path is loaded in the airborne companion (ADR-004 process-level isolation). Replaces the earlier "post-landing upload tool" naming after Plan-cycle scope expansion 2026-05-09. (source: user directive 2026-05-09)
|
**Tile Manager** — Operator-side component (C11) that owns both directions of network I/O against `satellite-provider`: pre-flight download (F1) into the local C6 store via the `TileDownloader` interface, and post-landing upload (F10) from C6 to the parent-suite ingest endpoint via the `TileUploader` interface (gated on `flight state == ON_GROUND`). Implemented as a separate binary / image so neither network path is loaded in the airborne companion (ADR-004 process-level isolation). Replaces the earlier "post-landing upload tool" naming after Plan-cycle scope expansion 2026-05-09. (source: user directive 2026-05-09)
|
||||||
|
|
||||||
@@ -74,6 +82,8 @@ 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`)
|
**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_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`)
|
**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`)
|
||||||
|
|
||||||
**Tile** — Unit of persistent imagery on the companion; basemap-projected, deduplicated; the only persistent imagery format. Mid-flight-generated tiles use the same on-disk format as `satellite-provider` (`./{zoomLevel}/{x}/{y}.jpg` + matching metadata schema) so post-landing upload is byte-identical. (source: AC-8.4, AC-8.5, parent-suite `satellite-provider/README.md`, user confirmation 2026-05-09)
|
**Tile** — Unit of persistent imagery on the companion; basemap-projected, deduplicated; the only persistent imagery format. Mid-flight-generated tiles use the same on-disk format as `satellite-provider` (`./{zoomLevel}/{x}/{y}.jpg` + matching metadata schema) so post-landing upload is byte-identical. (source: AC-8.4, AC-8.5, parent-suite `satellite-provider/README.md`, user confirmation 2026-05-09)
|
||||||
@@ -93,3 +103,5 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
|
|||||||
**Visual propagated** — Source label `visual_propagated`: estimate produced by VIO frame-to-frame propagation with no fresh satellite anchor. Mid-confidence. (source: AC-1.4)
|
**Visual propagated** — Source label `visual_propagated`: estimate produced by VIO frame-to-frame propagation with no fresh satellite anchor. Mid-confidence. (source: AC-1.4)
|
||||||
|
|
||||||
**VPR / Visual Place Recognition** — Descriptor-based retrieval of the nearest satellite tile to the current nav frame (component C2). (source: `solution.md` §C2)
|
**VPR / Visual Place Recognition** — Descriptor-based retrieval of the nearest satellite tile to the current nav frame (component C2). (source: `solution.md` §C2)
|
||||||
|
|
||||||
|
**Waypoint** — Ordered `(lat, lon, alt, objective, source)` entry inside a `Flight`. Operationally meaningful ordering: `waypoints[0]` is the planned takeoff point and is extracted by C12 `FlightsApiClient` as the takeoff origin. C12 envelopes all waypoint lat/lon to derive the cache bbox. DTO shape mirrors `suite/flights/Database/Entities/Waypoint.cs`. (source: ADR-010, AZ-489)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
|
|
||||||
| # | Flow Name | Trigger | Primary Components | Criticality |
|
| # | Flow Name | Trigger | Primary Components | Criticality |
|
||||||
|---|-----------|---------|--------------------|-------------|
|
|---|-----------|---------|--------------------|-------------|
|
||||||
| F1 | Pre-flight cache provisioning | Operator runs C12 cache-build CLI on workstation | C12 (operator), C11 `TileDownloader`, [[`satellite-provider`]], C10, C6, C7 | High |
|
| F1 | Pre-flight cache provisioning | Operator runs C12 cache-build CLI on workstation with `--flight-id <Guid>` (online) or `--flight-file <path>` (offline). The flight was previously authored in the parent-suite Mission Planner UI (`suite/ui`) and persisted to the parent-suite `flights` REST service | C12 (operator), C12 `FlightsApiClient` (operator-side, AZ-489), [[`flights` REST service]], C11 `TileDownloader`, [[`satellite-provider`]], C10, C6, C7 | High |
|
||||||
| F2 | Takeoff load | Companion boot detected by FC `MAV_STATE` ARMED OR companion process start with armed FC | C10, C7, C8 (signing handshake), C13 | High |
|
| F2 | Takeoff load | Companion boot detected by FC `MAV_STATE` ARMED OR companion process start with armed FC | C10, C7, C8 (signing handshake), C5 `set_takeoff_origin` (operator-origin warm-start, AZ-490), C13 | High |
|
||||||
| F3 | Steady-state per-frame estimation | Nav camera frame received (3 Hz nominal) | C1, C2, C2.5, C3, C3.5, C4, C5, C8 (out), C13 | High |
|
| F3 | Steady-state per-frame estimation | Nav camera frame received (3 Hz nominal) | C1, C2, C2.5, C3, C3.5, C4, C5, C8 (out), C13 | High |
|
||||||
| F4 | Mid-flight tile generation + local cache write | Successful satellite-anchored frame with quality metadata above threshold | C5, C6, C13 (no C8/C11 path — C11 `TileUploader` is not loaded in the airborne image) | High |
|
| F4 | Mid-flight tile generation + local cache write | Successful satellite-anchored frame with quality metadata above threshold | C5, C6, C13 (no C8/C11 path — C11 `TileUploader` is not loaded in the airborne image) | High |
|
||||||
| F5 | Visual blackout + spoofed-GPS failsafe | Camera unusable AND/OR FC GPS reports denial/spoof | C1, C5, C8, C13 (degraded-mode escalation per AC-NEW-8) | High |
|
| F5 | Visual blackout + spoofed-GPS failsafe | Camera unusable AND/OR FC GPS reports denial/spoof | C1, C5, C8, C13 (degraded-mode escalation per AC-NEW-8) | High |
|
||||||
@@ -43,17 +43,19 @@
|
|||||||
|
|
||||||
### Description
|
### Description
|
||||||
|
|
||||||
The operator builds (or refreshes) the per-mission cache before takeoff. F1 has **two phases** sequenced by C12 OperatorTool:
|
The operator builds (or refreshes) the per-mission cache before takeoff. F1 has **three phases** sequenced by C12 OperatorTool:
|
||||||
|
|
||||||
- **Phase 1 — Tile download (C11 `TileDownloader`)**: fetch tiles from `satellite-provider` for the operational area; apply sector-classified freshness rules (AC-NEW-6) and resolution gate (RESTRICT-SAT-4); write tile rows + JPEGs into C6.
|
- **Phase 0 — Flight resolve (C12 `FlightsApiClient`, AZ-489)**: read the operator-authored `Flight` (ordered waypoints + altitudes) either from the parent-suite `flights` REST service (`--flight-id <Guid>`) or from a local JSON export (`--flight-file <path>`). Compute the bounding box as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km). Extract `Flight.waypoints[0].(lat, lon, alt)` as the **takeoff origin**. Both are passed downstream as `BuildRequest` fields.
|
||||||
- **Phase 2 — Cache artifact build (C10 CacheProvisioner)**: read the populated C6 store; compile/deserialize TRT engines via C7; batch-generate descriptors via the C2 backbone; atomically write the FAISS HNSW index with SHA-256 sidecars; write the Manifest hashing model + calibration + corpus + sector classification.
|
- **Phase 1 — Tile download (C11 `TileDownloader`)**: fetch tiles from `satellite-provider` for the bbox computed in Phase 0; apply sector-classified freshness rules (AC-NEW-6) and resolution gate (RESTRICT-SAT-4); write tile rows + JPEGs into C6.
|
||||||
|
- **Phase 2 — Cache artifact build (C10 CacheProvisioner)**: read the populated C6 store; compile/deserialize TRT engines via C7; batch-generate descriptors via the C2 backbone; atomically write the FAISS HNSW index with SHA-256 sidecars; write the Manifest hashing model + calibration + corpus + sector classification **+ takeoff origin** (D-C10-1 idempotence; ADR-010).
|
||||||
|
|
||||||
This flow is offline and not time-critical. **Only Phase 1 reaches `satellite-provider`** — and it runs on the operator workstation, which is the only host that holds the TLS + service-internal API key. The companion never reaches `satellite-provider` directly.
|
This flow is offline and not time-critical. **Only Phase 0 reaches `flights` REST and Phase 1 reaches `satellite-provider`** — both run on the operator workstation, which is the only host that holds TLS + service-internal credentials. The companion never reaches either service directly (Principle #9 — denied-environment operation).
|
||||||
|
|
||||||
### Preconditions
|
### Preconditions
|
||||||
|
|
||||||
- Operator workstation has network reach to `satellite-provider` (TLS + service-internal API key).
|
- Operator workstation has network reach to `satellite-provider` (TLS + service-internal API key).
|
||||||
- Operator has classified the operational area (`active_conflict | stable_rear`) — drives the freshness threshold (AC-8.2 / AC-NEW-6).
|
- Operator has classified the operational area (`active_conflict | stable_rear`) — drives the freshness threshold (AC-8.2 / AC-NEW-6).
|
||||||
|
- **Mission already authored in the parent-suite Mission Planner UI (`suite/ui`)** and persisted to the parent-suite `flights` REST service. Operator knows the `Flight` GUID (online path) OR has a JSON export of the same DTO shape on disk (offline path).
|
||||||
- Camera calibration JSON for the deployed unit is available (`adti20.<unit-id>.json` from D-PROJ-1 hybrid).
|
- Camera calibration JSON for the deployed unit is available (`adti20.<unit-id>.json` from D-PROJ-1 hybrid).
|
||||||
- Companion is connected to the operator workstation (USB or Ethernet) and writable.
|
- Companion is connected to the operator workstation (USB or Ethernet) and writable.
|
||||||
- Available cache budget on the companion's NVM is ≥ the projected `≤ 10 GB` per AC-8.3.
|
- Available cache budget on the companion's NVM is ≥ the projected `≤ 10 GB` per AC-8.3.
|
||||||
@@ -64,6 +66,8 @@ This flow is offline and not time-critical. **Only Phase 1 reaches `satellite-pr
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Operator
|
participant Operator
|
||||||
participant C12OperatorTool as C12 Operator Tool (workstation)
|
participant C12OperatorTool as C12 Operator Tool (workstation)
|
||||||
|
participant FlightsClient as C12 FlightsApiClient (workstation, AZ-489)
|
||||||
|
participant FlightsApi as [[flights REST service]] (.NET 8)
|
||||||
participant C11TileDownloader as C11 TileDownloader (workstation)
|
participant C11TileDownloader as C11 TileDownloader (workstation)
|
||||||
participant SatelliteProvider as [[satellite-provider]] (.NET 8)
|
participant SatelliteProvider as [[satellite-provider]] (.NET 8)
|
||||||
participant C6TileStore as C6 TileStore + DescriptorIndex (Postgres + filesystem + FAISS)
|
participant C6TileStore as C6 TileStore + DescriptorIndex (Postgres + filesystem + FAISS)
|
||||||
@@ -71,20 +75,31 @@ sequenceDiagram
|
|||||||
participant C7Inference as C7 InferenceRuntime
|
participant C7Inference as C7 InferenceRuntime
|
||||||
participant C2Backbone as C2 VPR backbone (TensorRT)
|
participant C2Backbone as C2 VPR backbone (TensorRT)
|
||||||
|
|
||||||
Operator->>C12OperatorTool: build_cache(area, sector_class, calibration_file)
|
Operator->>C12OperatorTool: build_cache --flight-id GUID [--flight-file PATH] sector_class calibration_file
|
||||||
|
alt online (flight-id)
|
||||||
|
C12OperatorTool->>FlightsClient: fetch_flight(GUID)
|
||||||
|
FlightsClient->>FlightsApi: GET /flights/{id} + GET /flights/{id}/waypoints
|
||||||
|
FlightsApi-->>FlightsClient: Flight DTO (waypoints, altitudes)
|
||||||
|
else offline (flight-file)
|
||||||
|
C12OperatorTool->>FlightsClient: load_flight_file(PATH)
|
||||||
|
FlightsClient->>FlightsClient: parse JSON into FlightDto
|
||||||
|
end
|
||||||
|
FlightsClient->>FlightsClient: bbox = envelope(waypoints.lat, waypoints.lon) + buffer
|
||||||
|
FlightsClient->>FlightsClient: takeoff_origin = waypoints[0].(lat, lon, alt)
|
||||||
|
FlightsClient-->>C12OperatorTool: (bbox, takeoff_origin, flight_id)
|
||||||
C12OperatorTool->>C11TileDownloader: download_tiles_for_area(bbox, zooms, sector_class)
|
C12OperatorTool->>C11TileDownloader: download_tiles_for_area(bbox, zooms, sector_class)
|
||||||
C11TileDownloader->>SatelliteProvider: GET /api/satellite/tiles?bbox=&zoom=
|
C11TileDownloader->>SatelliteProvider: GET /api/satellite/tiles?bbox=&zoom=
|
||||||
SatelliteProvider-->>C11TileDownloader: Tile blobs + metadata (paged)
|
SatelliteProvider-->>C11TileDownloader: Tile blobs + metadata (paged)
|
||||||
C11TileDownloader->>C11TileDownloader: filter by AC-NEW-6 freshness + RESTRICT-SAT-4 resolution
|
C11TileDownloader->>C11TileDownloader: filter by AC-NEW-6 freshness + RESTRICT-SAT-4 resolution
|
||||||
C11TileDownloader->>C6TileStore: write tiles to ./tiles/{zoomLevel}/{x}/{y}.jpg + Postgres rows (source='googlemaps')
|
C11TileDownloader->>C6TileStore: write tiles to ./tiles/{zoomLevel}/{x}/{y}.jpg + Postgres rows (source='googlemaps')
|
||||||
C11TileDownloader-->>C12OperatorTool: DownloadBatchReport (counts, freshness summary)
|
C11TileDownloader-->>C12OperatorTool: DownloadBatchReport (counts, freshness summary)
|
||||||
C12OperatorTool->>C10Provisioner: build_cache_artifacts(bbox, zooms, sector_class, calibration)
|
C12OperatorTool->>C10Provisioner: build_cache_artifacts(bbox, zooms, sector_class, calibration, takeoff_origin, flight_id)
|
||||||
C10Provisioner->>C7Inference: load VPR backbone ONNX
|
C10Provisioner->>C7Inference: load VPR backbone ONNX
|
||||||
C7Inference-->>C10Provisioner: TRT engine compiled (cached per SM/JP/TRT/precision tuple)
|
C7Inference-->>C10Provisioner: TRT engine compiled (cached per SM/JP/TRT/precision tuple)
|
||||||
C10Provisioner->>C2Backbone: per-tile descriptor generation (batched on Jetson, reads tiles from C6)
|
C10Provisioner->>C2Backbone: per-tile descriptor generation (batched on Jetson, reads tiles from C6)
|
||||||
C2Backbone-->>C10Provisioner: descriptor matrix (FP16/INT8 per D-C7-1)
|
C2Backbone-->>C10Provisioner: descriptor matrix (FP16/INT8 per D-C7-1)
|
||||||
C10Provisioner->>C6TileStore: faiss.write_index (HNSW) + atomicwrites + SHA-256 content-hash
|
C10Provisioner->>C6TileStore: faiss.write_index (HNSW) + atomicwrites + SHA-256 content-hash
|
||||||
C10Provisioner->>C10Provisioner: write Manifest (hash of model + calibration + corpus + sector_class)
|
C10Provisioner->>C10Provisioner: write Manifest (hash of model + calibration + corpus + sector_class + takeoff_origin)
|
||||||
C10Provisioner-->>C12OperatorTool: BuildReport (counts, hashes)
|
C10Provisioner-->>C12OperatorTool: BuildReport (counts, hashes)
|
||||||
C12OperatorTool-->>Operator: PASS / FAIL summary
|
C12OperatorTool-->>Operator: PASS / FAIL summary
|
||||||
```
|
```
|
||||||
@@ -93,8 +108,12 @@ sequenceDiagram
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
Start([Operator invokes C12 build]) --> Classify[Operator classifies sector active_conflict OR stable_rear]
|
Start([Operator invokes C12 build with --flight-id or --flight-file]) --> ResolveFlight[C12 FlightsApiClient fetches Flight by GUID or reads JSON export]
|
||||||
Classify --> InvokeC11[C12 invokes C11 TileDownloader]
|
ResolveFlight --> FlightOk{Flight resolved + at least 1 waypoint?}
|
||||||
|
FlightOk -->|no| RefuseBuild[Refuse build with explicit error to operator]
|
||||||
|
FlightOk -->|yes| ComputeBbox[Compute bbox as envelope of waypoint lat/lon + buffer; take waypoints[0] as takeoff origin]
|
||||||
|
ComputeBbox --> Classify[Operator classifies sector active_conflict OR stable_rear]
|
||||||
|
Classify --> InvokeC11[C12 invokes C11 TileDownloader with computed bbox]
|
||||||
InvokeC11 --> Download[C11 GET /api/satellite/tiles for bbox + zoom]
|
InvokeC11 --> Download[C11 GET /api/satellite/tiles for bbox + zoom]
|
||||||
Download --> FreshnessFilter{Freshness ok per AC-8.2 + AC-NEW-6?}
|
Download --> FreshnessFilter{Freshness ok per AC-8.2 + AC-NEW-6?}
|
||||||
FreshnessFilter -->|stale and stable_rear| RejectOrDowngrade[Reject or downgrade tile]
|
FreshnessFilter -->|stale and stable_rear| RejectOrDowngrade[Reject or downgrade tile]
|
||||||
@@ -112,40 +131,51 @@ flowchart TD
|
|||||||
ReuseEngine --> Descriptors
|
ReuseEngine --> Descriptors
|
||||||
BuildEngine --> Descriptors[C10 batches each tile through C2 backbone for descriptors]
|
BuildEngine --> Descriptors[C10 batches each tile through C2 backbone for descriptors]
|
||||||
Descriptors --> WriteIndex[faiss.write_index HNSW + atomicwrites + SHA-256 content-hash]
|
Descriptors --> WriteIndex[faiss.write_index HNSW + atomicwrites + SHA-256 content-hash]
|
||||||
WriteIndex --> WriteManifest[Write Manifest with hash of model + calibration + corpus + sector_class]
|
WriteIndex --> WriteManifest[Write Manifest with hash of model + calibration + corpus + sector_class + takeoff_origin]
|
||||||
WriteManifest --> ManifestHashCheck{Idempotence check D-C10-1: same manifest hash as last build?}
|
WriteManifest --> ManifestHashCheck{Idempotence check D-C10-1: same manifest hash as last build?}
|
||||||
ManifestHashCheck -->|same| SkipRebuild[Skip rebuild and emit no-op report]
|
ManifestHashCheck -->|same| SkipRebuild[Skip rebuild and emit no-op report]
|
||||||
ManifestHashCheck -->|different| Done([Provisioning complete; cache + engines + manifest staged])
|
ManifestHashCheck -->|different| Done([Provisioning complete; cache + engines + manifest staged])
|
||||||
SkipRebuild --> Done
|
SkipRebuild --> Done
|
||||||
|
RefuseBuild --> Done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
| Step | From | To | Data | Format |
|
| Step | From | To | Data | Format |
|
||||||
|------|------|----|------|--------|
|
|------|------|----|------|--------|
|
||||||
| 1 | Operator | C12 | (`bounding_box`, `zoom_levels`, `sector_class`, `calibration_path`) | CLI args / GUI form |
|
| 0a | Operator | C12 | (`flight_id` OR `flight_file`, `zoom_levels`, `sector_class`, `calibration_path`) | CLI args / GUI form |
|
||||||
| 2 | C12 | C11 `TileDownloader` | `DownloadRequest` | in-process call |
|
| 0b | C12 `FlightsApiClient` (online) | `flights` REST | `GET /flights/{id}` + `GET /flights/{id}/waypoints` | HTTPS GET |
|
||||||
| 3 | C11 | `satellite-provider` REST | `GET /api/satellite/tiles?bbox=…&zoom=…` | HTTPS query |
|
| 0c | `flights` REST | C12 `FlightsApiClient` | `Flight` + ordered `Waypoint[]` (lat / lon / alt / objective / source) | JSON DTOs |
|
||||||
| 4 | `satellite-provider` | C11 | Paged tile blobs + metadata rows | JPEG + JSON metadata |
|
| 0d | C12 `FlightsApiClient` (offline) | filesystem | `flight_file` JSON in the same DTO shape | JSON read |
|
||||||
| 5 | C11 | C6 filesystem (over USB/Eth) | Tile JPEG bodies | `./tiles/{zoomLevel}/{x}/{y}.jpg` |
|
| 0e | C12 `FlightsApiClient` | C12 | `(bbox, takeoff_origin, flight_id)` | in-process |
|
||||||
| 6 | C11 | C6 PostgreSQL | Tile metadata rows (`source='googlemaps'`) | SQL INSERT (mirror of `satellite-provider`'s `tiles` table) |
|
| 1 | C12 | C11 `TileDownloader` | `DownloadRequest(bbox, zoom_levels, sector_class)` | in-process call |
|
||||||
| 7 | C12 | C10 `CacheProvisioner` | `BuildRequest` | in-process call (operator-tool side); RPC over USB/Eth to companion runner |
|
| 2 | C11 | `satellite-provider` REST | `GET /api/satellite/tiles?bbox=…&zoom=…` | HTTPS query |
|
||||||
| 8 | C10 → C7 | TRT engine cache | TRT engines | `.engine` files keyed by `(SM, JP, TRT, precision)` (D-C10-7) |
|
| 3 | `satellite-provider` | C11 | Paged tile blobs + metadata rows | JPEG + JSON metadata |
|
||||||
| 9 | C2 backbone (driven by C10) | C6 FAISS index | Descriptor matrix | `.index` (FAISS HNSW), atomicwrites, SHA-256 sidecar |
|
| 4 | C11 | C6 filesystem (over USB/Eth) | Tile JPEG bodies | `./tiles/{zoomLevel}/{x}/{y}.jpg` |
|
||||||
| 10 | C10 | filesystem | Manifest | YAML or JSON; carries hashes |
|
| 5 | C11 | C6 PostgreSQL | Tile metadata rows (`source='googlemaps'`) | SQL INSERT (mirror of `satellite-provider`'s `tiles` table) |
|
||||||
|
| 6 | C12 | C10 `CacheProvisioner` | `BuildRequest(bbox, zoom_levels, sector_class, calibration_path, takeoff_origin, flight_id)` | in-process call (operator-tool side); RPC over USB/Eth to companion runner |
|
||||||
|
| 7 | C10 → C7 | TRT engine cache | TRT engines | `.engine` files keyed by `(SM, JP, TRT, precision)` (D-C10-7) |
|
||||||
|
| 8 | C2 backbone (driven by C10) | C6 FAISS index | Descriptor matrix | `.index` (FAISS HNSW), atomicwrites, SHA-256 sidecar |
|
||||||
|
| 9 | C10 | filesystem | Manifest (carries `takeoff_origin` + hashes) | YAML or JSON |
|
||||||
|
|
||||||
### Error scenarios
|
### Error scenarios
|
||||||
|
|
||||||
| Error | Where | Detection | Recovery |
|
| Error | Where | Detection | Recovery |
|
||||||
|-------|-------|-----------|----------|
|
|-------|-------|-----------|----------|
|
||||||
| `satellite-provider` unreachable | Step 3 | HTTP timeout / 5xx | C11 `TileDownloader` fails with explicit error; operator retries when network is available; takeoff blocked |
|
| `flights` REST unreachable (online path) | Step 0b | HTTP timeout / connection refused | Fail explicitly; instruct operator to retry online or use `--flight-file` offline path; takeoff blocked |
|
||||||
| Tile fails freshness | Step 4 (C11) | `tile.capture_timestamp` vs `sector_class` threshold | Reject (active_conflict) or downgrade-no-`satellite_anchored`-label (rear), per AC-NEW-6; counts surface in `DownloadBatchReport` |
|
| `flights` REST 401/403 (online path) | Step 0b | HTTP 401/403 | Fail with explicit error; instruct operator to refresh suite credentials; takeoff blocked. Never silently fall back |
|
||||||
| Resolution below 0.5 m/px | Step 4 (C11) | Tile metadata GSD check (RESTRICT-SAT-4) | Reject; report; takeoff blocked |
|
| `flights` REST 404 (online path) | Step 0b | HTTP 404 | Fail with explicit message naming the unknown `flight_id`; takeoff blocked |
|
||||||
| Insufficient cache budget | Step 5 (C11) | Filesystem free-space check pre-write | Fail fast with explicit budget delta; no partial write |
|
| Flight file malformed (offline path) | Step 0d | JSON parse failure / schema mismatch | Fail with line / field reference; instruct operator to re-export from Mission Planner UI; takeoff blocked |
|
||||||
| C6 missing tiles for requested bbox/zoom | Step 7 (C10) | C10's pre-build scan finds < expected tile count | Surface as `BuildReport.failure` instructing operator to re-run C11 `TileDownloader`; do **not** trigger network fetch from C10 |
|
| Flight has zero waypoints | Step 0e | Post-fetch validation | Fail explicitly; cannot derive bbox or takeoff origin; takeoff blocked |
|
||||||
| Engine compile failure | Step 8 | Polygraphy / trtexec exit code; no output `.engine` | Surface error to operator; takeoff blocked; **never silently fall back** |
|
| Flight bbox exceeds cache budget | Step 0e | Pre-Phase-1 bbox area vs AC-8.3 budget projection | Fail with budget delta; operator must re-plan a smaller route in Mission Planner UI; takeoff blocked |
|
||||||
| Descriptor generation OOM on Jetson | Step 9 | CUDA OOM | Halve batch size and retry once; if still OOM, surface to operator |
|
| `satellite-provider` unreachable | Step 2 | HTTP timeout / 5xx | C11 `TileDownloader` fails with explicit error; operator retries when network is available; takeoff blocked |
|
||||||
| Atomic-write or SHA-256 mismatch | Step 9 | `atomicwrites` rollback or content-hash sidecar mismatch | Mark cache invalid; rebuild from staged tiles; if persistent, surface to operator |
|
| Tile fails freshness | Step 3 (C11) | `tile.capture_timestamp` vs `sector_class` threshold | Reject (active_conflict) or downgrade-no-`satellite_anchored`-label (rear), per AC-NEW-6; counts surface in `DownloadBatchReport` |
|
||||||
|
| Resolution below 0.5 m/px | Step 3 (C11) | Tile metadata GSD check (RESTRICT-SAT-4) | Reject; report; takeoff blocked |
|
||||||
|
| Insufficient cache budget | Step 4 (C11) | Filesystem free-space check pre-write | Fail fast with explicit budget delta; no partial write |
|
||||||
|
| C6 missing tiles for requested bbox/zoom | Step 6 (C10) | C10's pre-build scan finds < expected tile count | Surface as `BuildReport.failure` instructing operator to re-run C11 `TileDownloader`; do **not** trigger network fetch from C10 |
|
||||||
|
| Engine compile failure | Step 7 | Polygraphy / trtexec exit code; no output `.engine` | Surface error to operator; takeoff blocked; **never silently fall back** |
|
||||||
|
| Descriptor generation OOM on Jetson | Step 8 | CUDA OOM | Halve batch size and retry once; if still OOM, surface to operator |
|
||||||
|
| Atomic-write or SHA-256 mismatch | Step 8 | `atomicwrites` rollback or content-hash sidecar mismatch | Mark cache invalid; rebuild from staged tiles; if persistent, surface to operator |
|
||||||
| Tampered cache (post-write, pre-takeoff) | (caught at takeoff in F2, not here) | F2 SHA-256 content-hash gate | F2 refuses takeoff (IT-7) |
|
| Tampered cache (post-write, pre-takeoff) | (caught at takeoff in F2, not here) | F2 SHA-256 content-hash gate | F2 refuses takeoff (IT-7) |
|
||||||
|
|
||||||
### Performance expectations
|
### Performance expectations
|
||||||
@@ -203,11 +233,19 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
Companion->>FC: subscribe to FC IMU + attitude + GPS health (telemetry)
|
Companion->>FC: subscribe to FC IMU + attitude + GPS health (telemetry)
|
||||||
FC-->>Companion: first telemetry frame
|
FC-->>Companion: first telemetry frame
|
||||||
Companion->>FC: query FC EKF last valid GPS + IMU-extrapolated pose (AC-5.1)
|
Note over Companion,Pipeline: Cold-start ladder (ADR-010, AZ-490). Operator-origin from Manifest is primary; FC EKF GPS is secondary
|
||||||
FC-->>Companion: warm-start pose
|
alt Manifest carries takeoff_origin (AZ-490 primary path)
|
||||||
Companion->>Pipeline: warm with calibration + warm-start pose
|
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
|
||||||
|
Companion->>Pipeline: C5.set_takeoff_origin(fc_gps_origin, fc_gps_sigma)
|
||||||
|
else No origin available
|
||||||
|
Companion-->>Companion: stay INITIALIZING; FT-P-11 takeoff-abort policy (AZ-419 amended)
|
||||||
|
end
|
||||||
|
Companion->>Pipeline: warm pipeline with calibration
|
||||||
Pipeline-->>Companion: ready (no estimate emitted yet)
|
Pipeline-->>Companion: ready (no estimate emitted yet)
|
||||||
Companion->>Fdr: open per-flight FDR record; log signing key rotation event
|
Companion->>Fdr: open per-flight FDR record; log signing key rotation event + chosen cold-start origin source
|
||||||
Note over Companion: Wait for first nav frame (F3 entry)
|
Note over Companion: Wait for first nav frame (F3 entry)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -227,11 +265,19 @@ flowchart TD
|
|||||||
FcDetect -->|iNav| InavOpen[Open MSP2 channel unsigned residual risk]
|
FcDetect -->|iNav| InavOpen[Open MSP2 channel unsigned residual risk]
|
||||||
ApSign --> SignOk{Signing handshake OK?}
|
ApSign --> SignOk{Signing handshake OK?}
|
||||||
SignOk -->|no| RefuseTakeoff
|
SignOk -->|no| RefuseTakeoff
|
||||||
SignOk -->|yes| WarmStart
|
SignOk -->|yes| OriginGate
|
||||||
InavOpen --> WarmStart[Query FC EKF last valid GPS + IMU-extrapolated pose AC-5.1]
|
InavOpen --> OriginGate{Manifest carries takeoff_origin?}
|
||||||
WarmStart --> WarmPipeline[Warm C1 + C2 + C2.5 + C3 + C3.5 + C4 + C5 with calibration + warm-start pose]
|
OriginGate -->|yes ADR-010 AZ-490 primary| OperatorOrigin[C5.set_takeoff_origin manifest.takeoff_origin sigma_horiz_m sigma_vert_m]
|
||||||
WarmPipeline --> OpenFdr[C13 opens per-flight FDR; logs signing key rotation event]
|
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_horiz fc_gps_sigma_vert]
|
||||||
|
FcEkfGate -->|no| NoOrigin[Stay INITIALIZING and apply FT-P-11 takeoff-abort policy]
|
||||||
|
OperatorOrigin --> WarmPipeline
|
||||||
|
FcOrigin --> WarmPipeline
|
||||||
|
NoOrigin --> Refuse2[Refuse takeoff with FDR record of missing origin]
|
||||||
|
WarmPipeline[Warm C1 + C2 + C2.5 + C3 + C3.5 + C4 + C5 with calibration]
|
||||||
|
WarmPipeline --> OpenFdr[C13 opens per-flight FDR; logs signing key rotation event + chosen origin source]
|
||||||
OpenFdr --> Ready([Ready; awaiting first nav frame])
|
OpenFdr --> Ready([Ready; awaiting first nav frame])
|
||||||
|
Refuse2 --> RefuseTakeoff
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data flow
|
### Data flow
|
||||||
@@ -239,12 +285,13 @@ flowchart TD
|
|||||||
| Step | From | To | Data | Format |
|
| Step | From | To | Data | Format |
|
||||||
|------|------|----|------|--------|
|
|------|------|----|------|--------|
|
||||||
| 1 | Companion | C10 | (`manifest_path`) | filesystem read |
|
| 1 | Companion | C10 | (`manifest_path`) | filesystem read |
|
||||||
| 2 | C10 | filesystem | content-hash sidecars | SHA-256 hex digests |
|
| 2 | C10 | filesystem | content-hash sidecars + `takeoff_origin` | SHA-256 hex digests + `LatLonAlt` in Manifest |
|
||||||
| 3 | Companion | FAISS | `.index` mmap pointer | C++ FAISS API |
|
| 3 | Companion | FAISS | `.index` mmap pointer | C++ FAISS API |
|
||||||
| 4 | Companion | C7 / TensorRT | `.engine` deserialize | TensorRT IRuntime |
|
| 4 | Companion | C7 / TensorRT | `.engine` deserialize | TensorRT IRuntime |
|
||||||
| 5 | Companion | FC (AP) | signing seed + handshake | MAVLink 2.0 signing |
|
| 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) |
|
| 6 | FC | Companion | warm-start pose + IMU/attitude/GPS health | MAVLink (AP) / MSP2 + MAVLink outbound (iNav) |
|
||||||
| 7 | Companion | C13 FDR | startup record (config snapshot, signing key rotation event, content-hash digests) | FDR record |
|
| 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
|
### Error scenarios
|
||||||
|
|
||||||
@@ -255,8 +302,9 @@ flowchart TD
|
|||||||
| TRT deserialize failure | Step 4 | TensorRT API error | Refuse takeoff; report mismatched `(SM, JP, TRT, precision)` tuple to operator |
|
| TRT deserialize failure | Step 4 | TensorRT API error | Refuse takeoff; report mismatched `(SM, JP, TRT, precision)` tuple to operator |
|
||||||
| Signing handshake fail (AP) | Step 5 | Handshake timeout / signed-message rejection | Refuse takeoff; clear-text reason via STATUSTEXT (handshake never succeeded → unsigned STATUSTEXT is acceptable for this case only) |
|
| Signing handshake fail (AP) | Step 5 | Handshake timeout / signed-message rejection | Refuse takeoff; clear-text reason via STATUSTEXT (handshake never succeeded → unsigned STATUSTEXT is acceptable for this case only) |
|
||||||
| FC unreachable | Step 6 | UART/USB read timeout | Retry with backoff; after `N` retries refuse takeoff |
|
| FC unreachable | Step 6 | UART/USB read timeout | Retry with backoff; after `N` retries refuse takeoff |
|
||||||
| EKF returns no warm-start pose | Step 6 | Empty `GLOBAL_POSITION_INT` and no IMU prior | Defer pipeline warm-up until first valid prior; bound the wait by AC-NEW-1 budget; if exceeded, refuse takeoff |
|
| Manifest has no `takeoff_origin` AND EKF returns no warm-start pose | Step 7 (ADR-010, AZ-490) | Both primary (manifest) and secondary (FC EKF) origin paths unavailable | Refuse takeoff; FDR records "no cold-start origin available"; FT-P-11 takeoff-abort policy applies. Bound the wait by AC-NEW-1 budget before final refusal |
|
||||||
| FDR open failure | Step 7 | Filesystem write error | Refuse takeoff (per AC-NEW-3 every payload class must be present from t=0) |
|
| Manifest has `takeoff_origin` but FC EKF GPS disagrees by > 200 m at takeoff | Step 7 (ADR-010 Principle #11 bounded-delta) | Operator origin vs FC GPS comparison after first FC telemetry frame | Operator origin wins; FC GPS is logged as suspect (likely spoofed-at-takeoff); proceed to warm pipeline with the operator origin |
|
||||||
|
| FDR open failure | Step 8 | Filesystem write error | Refuse takeoff (per AC-NEW-3 every payload class must be present from t=0) |
|
||||||
|
|
||||||
### Performance expectations
|
### Performance expectations
|
||||||
|
|
||||||
@@ -698,6 +746,8 @@ When the FC reports GPS denial/spoof while the companion estimate is healthy, th
|
|||||||
|
|
||||||
This flow is a **hot path**: AC-NEW-2 ≤ 3 s p95 from spoof onset to companion estimate becoming primary. Status: D-C8-2 = (b) is `Selected with runtime gate` — IT-3 SITL validation is the lock gate (Mode B Fact #111).
|
This flow is a **hot path**: AC-NEW-2 ≤ 3 s p95 from spoof onset to companion estimate becoming primary. Status: D-C8-2 = (b) is `Selected with runtime gate` — IT-3 SITL validation is the lock gate (Mode B Fact #111).
|
||||||
|
|
||||||
|
**Reverse path — mid-flight FC GPS re-promotion (ADR-010, Principle #11 amended)**: when the FC's GPS subsequently recovers in flight, the companion does **not** auto-yield. The FC GPS is fused back into C5 via `add_pose_anchor` **only after** the three-part gate fires: (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 bounded-delta gate — it catches "FC reports stable GPS but the value is wrong". When the gate passes, the FC GPS becomes one more anchor source, not an override. The source-set switch back to set 1 happens through the existing AC-NEW-8 path.
|
||||||
|
|
||||||
### Preconditions
|
### Preconditions
|
||||||
|
|
||||||
- ArduPilot Plane FC (D-C8-2 only applies to AP path).
|
- ArduPilot Plane FC (D-C8-2 only applies to AP path).
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Dependencies Table
|
# Dependencies Table
|
||||||
|
|
||||||
**Date**: 2026-05-10 (refreshed after E-BBT decomposition)
|
**Date**: 2026-05-11 (refreshed after AZ-489 + AZ-490 onboarding for ADR-010 operator-origin path)
|
||||||
**Total Tasks**: 140 (99 product + 41 blackbox-test)
|
**Total Tasks**: 142 (101 product + 41 blackbox-test)
|
||||||
**Total Complexity Points**: 472 (339 product + 133 blackbox-test)
|
**Total Complexity Points**: 478 (345 product + 133 blackbox-test)
|
||||||
|
|
||||||
Dependencies columns list only the tracker-ID portion (descriptive tail
|
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||||
text in each task spec is omitted here for table-readability). The
|
text in each task spec is omitted here for table-readability). The
|
||||||
authoritative dependency narrative — including "co-developed", "forward
|
authoritative dependency narrative — including "co-developed", "forward
|
||||||
dependency", and helper-vs-Protocol distinctions — lives in each task's
|
dependency", and helper-vs-Protocol distinctions — lives in each task's
|
||||||
own `Dependencies:` field. The graph is a strict DAG: a topological
|
own `Dependencies:` field. The graph is a strict DAG: a topological
|
||||||
traversal visits all 140 tasks. The 13 forward edges (dep ID > task ID)
|
traversal visits all 142 tasks. The 15 forward edges (dep ID > task ID)
|
||||||
are all declared and documented below under **Cycle Check**.
|
are all declared and documented below under **Cycle Check**.
|
||||||
|
|
||||||
| Task | Name | Complexity | Dependencies | Epic |
|
| Task | Name | Complexity | Dependencies | Epic |
|
||||||
@@ -61,9 +61,9 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
| AZ-323 | C10 Manifest Builder | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281, AZ-303 | AZ-252 |
|
| AZ-323 | C10 Manifest Builder | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281, AZ-303 | AZ-252 |
|
||||||
| AZ-324 | C10 ManifestVerifier | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281 | AZ-252 |
|
| AZ-324 | C10 ManifestVerifier | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281 | AZ-252 |
|
||||||
| AZ-325 | C10 CacheProvisioner | 3 | AZ-263, AZ-269, AZ-266, AZ-303, AZ-321, AZ-322, AZ-323 | AZ-252 |
|
| AZ-325 | C10 CacheProvisioner | 3 | AZ-263, AZ-269, AZ-266, AZ-303, AZ-321, AZ-322, AZ-323 | AZ-252 |
|
||||||
| AZ-326 | C12 CLI App | 3 | AZ-263, AZ-269, AZ-266 | AZ-253 |
|
| AZ-326 | C12 CLI App | 3 | AZ-263, AZ-269, AZ-266, AZ-489 | AZ-253 |
|
||||||
| AZ-327 | C12 Companion Bringup | 3 | AZ-263, AZ-269, AZ-266 | AZ-253 |
|
| AZ-327 | C12 Companion Bringup | 3 | AZ-263, AZ-269, AZ-266 | AZ-253 |
|
||||||
| AZ-328 | C12 Build-Cache Orchestrator | 5 | AZ-326, AZ-327, AZ-316, AZ-325, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
| AZ-328 | C12 Build-Cache Orchestrator | 5 | AZ-326, AZ-327, AZ-316, AZ-325, AZ-489, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
||||||
| AZ-329 | C12 Post-Landing Upload | 3 | AZ-326, AZ-319, AZ-272, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
| AZ-329 | C12 Post-Landing Upload | 3 | AZ-326, AZ-319, AZ-272, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
||||||
| AZ-330 | C12 OperatorReLocService | 3 | AZ-326, AZ-273, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
| AZ-330 | C12 OperatorReLocService | 3 | AZ-326, AZ-273, AZ-263, AZ-269, AZ-266 | AZ-253 |
|
||||||
| AZ-331 | C1 VioStrategy Protocol | 3 | AZ-263, AZ-269, AZ-266, AZ-270, AZ-272, AZ-276, AZ-277 | AZ-254 |
|
| AZ-331 | C1 VioStrategy Protocol | 3 | AZ-263, AZ-269, AZ-266, AZ-270, AZ-272, AZ-276, AZ-277 | AZ-254 |
|
||||||
@@ -126,7 +126,7 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
| AZ-416 | FT-P-09-AP — ArduPilot Plane GPS_INPUT contract + MAVLink 2.0 signing handshake | 5 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-416 | FT-P-09-AP — ArduPilot Plane GPS_INPUT contract + MAVLink 2.0 signing handshake | 5 | AZ-406, AZ-407 | AZ-262 |
|
||||||
| AZ-417 | FT-P-09-iNav — iNav MSP2_SENSOR_GPS contract conformance | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-417 | FT-P-09-iNav — iNav MSP2_SENSOR_GPS contract conformance | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||||
| AZ-418 | FT-P-10 — GTSAM smoothing-loop look-back accuracy | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-418 | FT-P-10 — GTSAM smoothing-loop look-back accuracy | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||||
| AZ-419 | FT-P-11 — Cold-start initialization from FC EKF | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-419 | FT-P-11 — Cold-start init (operator-manifest primary + FC EKF secondary + bounded-delta gate)| 3 | AZ-406, AZ-407, AZ-489 (forward), AZ-490 (forward) | AZ-262 |
|
||||||
| AZ-420 | FT-P-12 + FT-P-13 — GCS downsample + GCS-originated re-loc command | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-420 | FT-P-12 + FT-P-13 — GCS downsample + GCS-originated re-loc command | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||||
| AZ-421 | FT-P-15 + FT-P-16 + FT-P-18 — Tile cache + offline + no-raw-retention | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-421 | FT-P-15 + FT-P-16 + FT-P-18 — Tile cache + offline + no-raw-retention | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||||
| AZ-422 | FT-P-17 + FT-N-06 — Mid-flight tile generation + freshness | 3 | AZ-406, AZ-407 | AZ-262 |
|
| AZ-422 | FT-P-17 + FT-N-06 — Mid-flight tile generation + freshness | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||||
@@ -154,6 +154,8 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
| AZ-444 | Tier-2 Jetson harness wrapper — run-tier2.sh, ssh provisioning, systemd, ASan-fuzz | 5 | AZ-406 | AZ-262 |
|
| AZ-444 | Tier-2 Jetson harness wrapper — run-tier2.sh, ssh provisioning, systemd, ASan-fuzz | 5 | AZ-406 | AZ-262 |
|
||||||
| AZ-445 | CSV reporter + evidence bundler — per-NFR machine-readable outputs + traceability-status.json | 2 | AZ-406 | AZ-262 |
|
| AZ-445 | CSV reporter + evidence bundler — per-NFR machine-readable outputs + traceability-status.json | 2 | AZ-406 | AZ-262 |
|
||||||
| AZ-446 | CSV reporter refinements — trend-line + acceptance-band annotations + Monte Carlo CI | 2 | AZ-406, AZ-445 | AZ-262 |
|
| AZ-446 | CSV reporter refinements — trend-line + acceptance-band annotations + Monte Carlo CI | 2 | AZ-406, AZ-445 | AZ-262 |
|
||||||
|
| AZ-489 | C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback | 3 | AZ-263, AZ-269, AZ-266, AZ-279, AZ-280 | AZ-253 |
|
||||||
|
| AZ-490 | C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest | 3 | AZ-263, AZ-269, AZ-266, AZ-272, AZ-273, AZ-279, AZ-381, AZ-383, AZ-384, AZ-385, AZ-386 | AZ-260 |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@@ -189,6 +191,23 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
`blackout_spoof.py`; NFT-RES-04 is the focused 35 s escalation
|
`blackout_spoof.py`; NFT-RES-04 is the focused 35 s escalation
|
||||||
scenario while FT-N-04 covers the 5 s / 15 s / 35 s ladder.
|
scenario while FT-N-04 covers the 5 s / 15 s / 35 s ladder.
|
||||||
- AZ-446 depends on AZ-445 — refinements layer over the bundler.
|
- AZ-446 depends on AZ-445 — refinements layer over the bundler.
|
||||||
|
- **ADR-010 operator-origin path** (added 2026-05-11):
|
||||||
|
- **AZ-489 (C12 FlightsApiClient)** is the new read-only Flight
|
||||||
|
resolver for C12; it has no consumers inside its own epic but
|
||||||
|
feeds AZ-326 (CLI flags) and AZ-328 (orchestrator phase 0) — both
|
||||||
|
declare a hard backward dep on AZ-489. The CLI's `--flight-id` /
|
||||||
|
`--flight-file` flags + AZ-328's flight-resolve phase 0 cannot
|
||||||
|
land without it.
|
||||||
|
- **AZ-490 (C5 set_takeoff_origin)** extends the AZ-381 Protocol
|
||||||
|
with the pre-takeoff entrypoint, amends the AZ-385 source-label
|
||||||
|
state machine with the third bounded-delta clause, and depends
|
||||||
|
on AZ-381..AZ-386 (Protocol + factor adds + marginals + source
|
||||||
|
label gate + ESKF baseline) plus AZ-272/273/279 for FDR + Vincenty.
|
||||||
|
All deps are backward; AZ-490 ships after the C5 epic core lands.
|
||||||
|
- **AZ-419 (FT-P-11 cold-start)** carries forward deps on both
|
||||||
|
AZ-489 + AZ-490 — the blackbox cold-start scenario now exercises
|
||||||
|
the operator-manifest primary path (needs both) AND the FC EKF
|
||||||
|
secondary fallback (back-compat).
|
||||||
- **All E-BBT tasks depend on AZ-406 (test infrastructure)**; this is
|
- **All E-BBT tasks depend on AZ-406 (test infrastructure)**; this is
|
||||||
by design — AZ-406 is the foundation every blackbox test depends on
|
by design — AZ-406 is the foundation every blackbox test depends on
|
||||||
(analogous to AZ-263 for the product side).
|
(analogous to AZ-263 for the product side).
|
||||||
@@ -202,13 +221,13 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
- C3 `CrossDomainMatcher` → AZ-344 (Protocol) + AZ-345/346/347 (concrete)
|
- C3 `CrossDomainMatcher` → AZ-344 (Protocol) + AZ-345/346/347 (concrete)
|
||||||
- C3.5 `ConditionalRefiner` → AZ-348 (Protocol + Passthrough) + AZ-349 (AdHoP)
|
- C3.5 `ConditionalRefiner` → AZ-348 (Protocol + Passthrough) + AZ-349 (AdHoP)
|
||||||
- C4 `PoseEstimator` → AZ-355 (Protocol) + AZ-358/361 (concrete)
|
- C4 `PoseEstimator` → AZ-355 (Protocol) + AZ-358/361 (concrete)
|
||||||
- C5 `StateEstimator` → AZ-381 (Protocol) + AZ-382..AZ-389 (concrete)
|
- C5 `StateEstimator` → AZ-381 (Protocol) + AZ-382..AZ-389 (concrete) + AZ-490 (`set_takeoff_origin` entrypoint + bounded-delta gate)
|
||||||
- C6 `TileStore` / `DescriptorIndex` → AZ-303 (Interfaces) + AZ-304/305/306/307/308
|
- C6 `TileStore` / `DescriptorIndex` → AZ-303 (Interfaces) + AZ-304/305/306/307/308
|
||||||
- C7 `InferenceRuntime` → AZ-297 (Protocol) + AZ-298/299/300/301/302
|
- C7 `InferenceRuntime` → AZ-297 (Protocol) + AZ-298/299/300/301/302
|
||||||
- C8 `FcAdapter` / `GcsAdapter` → AZ-390 (Protocols) + AZ-391..AZ-397
|
- C8 `FcAdapter` / `GcsAdapter` → AZ-390 (Protocols) + AZ-391..AZ-397
|
||||||
- C10 Provisioning → AZ-321/322/323/324/325
|
- C10 Provisioning → AZ-321/322/323/324/325
|
||||||
- C11 Tile Manager → AZ-316/317/318/319/320
|
- C11 Tile Manager → AZ-316/317/318/319/320
|
||||||
- C12 Operator Tooling → AZ-326/327/328/329/330
|
- C12 Operator Tooling → AZ-326/327/328/329/330 + AZ-489 (FlightsApiClient)
|
||||||
- C13 FDR Writer → AZ-291..AZ-296
|
- C13 FDR Writer → AZ-291..AZ-296
|
||||||
|
|
||||||
- **Cross-cutting product modules**:
|
- **Cross-cutting product modules**:
|
||||||
@@ -244,7 +263,7 @@ are all declared and documented below under **Cycle Check**.
|
|||||||
## Cycle Check
|
## Cycle Check
|
||||||
|
|
||||||
A static dependency-graph traversal (Kahn topological sort) visits all
|
A static dependency-graph traversal (Kahn topological sort) visits all
|
||||||
140 nodes — no cycles. The 13 forward edges (dep ID > task ID) are all
|
142 nodes — no cycles. The 15 forward edges (dep ID > task ID) are all
|
||||||
declared, bounded, and documented:
|
declared, bounded, and documented:
|
||||||
|
|
||||||
- **AZ-267 → AZ-272** (FDR Log Bridge → FdrRecord Schema; shipped in
|
- **AZ-267 → AZ-272** (FDR Log Bridge → FdrRecord Schema; shipped in
|
||||||
@@ -261,6 +280,13 @@ declared, bounded, and documented:
|
|||||||
optionally for the ASan-fuzz mode). AZ-444 is therefore scheduled
|
optionally for the ASan-fuzz mode). AZ-444 is therefore scheduled
|
||||||
as the first Tier-2 E-BBT deliverable; the dependent scenarios land
|
as the first Tier-2 E-BBT deliverable; the dependent scenarios land
|
||||||
on top of it.
|
on top of it.
|
||||||
|
- **AZ-326 → AZ-489, AZ-328 → AZ-489** (C12 CLI + orchestrator
|
||||||
|
depend on the new C12 FlightsApiClient task added 2026-05-11; the
|
||||||
|
client lands first inside the C12 epic and the CLI/orchestrator
|
||||||
|
then plug it in).
|
||||||
|
- **AZ-419 → AZ-489, AZ-419 → AZ-490** (blackbox cold-start scenario
|
||||||
|
forward-depends on both the C12 client + the new C5 entrypoint;
|
||||||
|
the scenario lands after both product tasks).
|
||||||
|
|
||||||
The graph is therefore a strict DAG once these documented forward
|
The graph is therefore a strict DAG once these documented forward
|
||||||
edges are accounted for, and remains sortable by tracker ID modulo
|
edges are accounted for, and remains sortable by tracker ID modulo
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# C12 FlightsApiClient — Fetch Flight from suite flights service + offline JSON fallback
|
||||||
|
|
||||||
|
**Task**: AZ-489_c12_flights_api_client
|
||||||
|
**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback
|
||||||
|
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
|
||||||
|
**Complexity**: 3 points
|
||||||
|
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math)
|
||||||
|
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
||||||
|
**Tracker**: AZ-489
|
||||||
|
**Epic**: AZ-253 (E-C12)
|
||||||
|
|
||||||
|
### Document Dependencies
|
||||||
|
|
||||||
|
- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
|
||||||
|
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
|
||||||
|
- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor).
|
||||||
|
- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Without `FlightsApiClient`:
|
||||||
|
|
||||||
|
- ADR-010's primary cold-start path (operator-planned mission → C10 Manifest `takeoff_origin` → C5 warm-start) cannot be wired. C10 has nowhere to get the origin from; C5 has nothing to seed.
|
||||||
|
- F1 phase 0 (Flight resolve) defined in `_docs/02_document/system-flows.md` cannot run; AZ-328's flight-resolve phase has no service to invoke.
|
||||||
|
- C12-CLI's `--flight-id` / `--flight-file` flags (AZ-326) have nothing to delegate to.
|
||||||
|
- The bbox in `BuildCacheRequest` would need to keep coming from operator-typed CLI args, drifting from the canonical mission authored in the Mission Planner UI — exactly the operator-error vector ADR-010 was created to remove.
|
||||||
|
- The offline path (`--flight-file`) has no implementation, so operator workstations without a path to the flights service can't run F1.
|
||||||
|
- The bbox buffer is currently undefined; without a contract there's no single source of truth for "how big around the route do we cache tiles".
|
||||||
|
|
||||||
|
This task delivers the client + its frozen contract. It does NOT modify the CLI flag parsing (AZ-326), the orchestrator phase ordering (AZ-328), the Manifest schema (AZ-323), or any C5 path.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- A `FlightsApiClient` Protocol + concrete `HttpxFlightsApiClient` implementation at `src/operator_tool/flights_api_client.py`:
|
||||||
|
- Constructor: `__init__(self, *, httpx_client: httpx.Client | None = None, wgs_converter: WgsConverter, logger: Logger, clock: Clock)`.
|
||||||
|
- Public methods per the contract: `fetch_flight`, `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight`.
|
||||||
|
- DTOs at `src/operator_tool/flights_dto.py`:
|
||||||
|
- `FlightDto` + `WaypointDto` as `@dataclass(frozen=True, slots=True)` with pydantic validators (via `pydantic.dataclasses.dataclass` or a standalone `pydantic.TypeAdapter`).
|
||||||
|
- `WaypointObjective` + `WaypointSource` enums mirroring the C# enums.
|
||||||
|
- Offline loader at `src/operator_tool/flights_file_loader.py`:
|
||||||
|
- `load_flight_file(path: Path) -> FlightDto` — reads JSON via `orjson`, validates against the same pydantic schema, raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` per the contract.
|
||||||
|
- Bbox helper at `src/operator_tool/bbox_from_waypoints.py`:
|
||||||
|
- `bbox_from_waypoints(waypoints, *, buffer_m, wgs_converter) -> BoundingBox` — envelopes the lat/lon, then inflates by `buffer_m` horizontal distance (NOT degree-space) using `wgs_converter.metres_to_degrees(lat, buffer_m)`.
|
||||||
|
- Error hierarchy at `src/operator_tool/flights_api_errors.py`:
|
||||||
|
- `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`.
|
||||||
|
- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
|
||||||
|
- Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`.
|
||||||
|
- `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`.
|
||||||
|
- Logging:
|
||||||
|
- INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line.
|
||||||
|
- WARN on retry attempts (`kind="c12.flights.fetch.retry"`).
|
||||||
|
- ERROR on each failure variant (`kind="c12.flights.fetch.failed"` with the resolved error class name).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Included
|
||||||
|
|
||||||
|
- `FlightsApiClient` Protocol + `HttpxFlightsApiClient` concrete impl.
|
||||||
|
- `FlightDto` + `WaypointDto` + enums.
|
||||||
|
- Online path (`fetch_flight`) with one-retry-on-transient-5xx-or-connect-error semantics per FAC-INV-5.
|
||||||
|
- Offline path (`load_flight_file`) reading the same DTO shape.
|
||||||
|
- `bbox_from_waypoints` envelope + horizontal-distance buffer via `WgsConverter`.
|
||||||
|
- `takeoff_origin_from_flight` pass-through of `waypoints[0]`.
|
||||||
|
- Error hierarchy with one-line operator-friendly text per class.
|
||||||
|
- Composition-root factory + service-dataclass extension.
|
||||||
|
- Unit tests covering every AC + Protocol conformance.
|
||||||
|
|
||||||
|
### Excluded
|
||||||
|
|
||||||
|
- Flight authoring / editing (Mission Planner UI owns).
|
||||||
|
- Live updates / websockets.
|
||||||
|
- Caching `FlightDto` across runs.
|
||||||
|
- Writing build status back to the `flights` REST service.
|
||||||
|
- Wiring `--flight-id` / `--flight-file` flags into the CLI (AZ-326).
|
||||||
|
- Wiring the resolved DTO into the orchestrator phase 0 (AZ-328).
|
||||||
|
- Anything that runs on the airborne companion (this is operator-workstation-only).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
**AC-1: Online happy path — `fetch_flight` returns a populated `FlightDto`**
|
||||||
|
Given a fake httpx transport that returns 200 OK with a valid 3-waypoint JSON body
|
||||||
|
When `fetch_flight(flight_id=..., base_url="https://flights.example", auth_token="abc")` is called
|
||||||
|
Then a `FlightDto` is returned with `flight_id`, `name`, and 3 ordered `WaypointDto` entries (ordinals 0, 1, 2); ONE INFO log; ZERO occurrences of `"abc"` in any log line
|
||||||
|
|
||||||
|
**AC-2: Online 404 — `FlightNotFoundError` with NO retry**
|
||||||
|
Given a fake transport that returns 404
|
||||||
|
When `fetch_flight(flight_id=<unknown>)` is called
|
||||||
|
Then `FlightNotFoundError` is raised; the unknown `flight_id` is in the message; the transport is hit exactly once (no retry); ONE ERROR log
|
||||||
|
|
||||||
|
**AC-3: Online 401 — `FlightsApiAuthError` with NO retry; auth_token NOT logged**
|
||||||
|
Given a fake transport that returns 401
|
||||||
|
When `fetch_flight(auth_token="bearer-xyz")` is called
|
||||||
|
Then `FlightsApiAuthError` is raised; transport hit exactly once; the literal `"bearer-xyz"` does NOT appear in any log line
|
||||||
|
|
||||||
|
**AC-4: Online 503 (transient) — one retry, then success**
|
||||||
|
Given a fake transport that returns 503 on the first call and 200 on the second
|
||||||
|
When `fetch_flight(...)` is called
|
||||||
|
Then the call succeeds; transport is hit exactly twice; ONE WARN log `kind="c12.flights.fetch.retry"`
|
||||||
|
|
||||||
|
**AC-5: Online 503 (persistent) — one retry, then `FlightsApiUnreachableError`**
|
||||||
|
Given a fake transport that always returns 503
|
||||||
|
When `fetch_flight(...)` is called
|
||||||
|
Then `FlightsApiUnreachableError` is raised; transport hit exactly twice; ONE ERROR log
|
||||||
|
|
||||||
|
**AC-6: Online schema drift — `FlightsApiSchemaError` with field reference**
|
||||||
|
Given a fake transport that returns 200 OK with a body missing the `lat` field on a waypoint
|
||||||
|
When `fetch_flight(...)` is called
|
||||||
|
Then `FlightsApiSchemaError` is raised; the message names the missing field
|
||||||
|
|
||||||
|
**AC-7: Offline happy path — `load_flight_file` returns equivalent `FlightDto`**
|
||||||
|
Given a JSON file on disk in the documented schema (3 waypoints, ordinals 0/1/2)
|
||||||
|
When `load_flight_file(path)` is called
|
||||||
|
Then a `FlightDto` is returned with the same shape as the online happy path
|
||||||
|
|
||||||
|
**AC-8: Offline file missing — `FlightFileNotFoundError`**
|
||||||
|
Given a path that does not exist
|
||||||
|
When `load_flight_file(path)` is called
|
||||||
|
Then `FlightFileNotFoundError` is raised; the path is in the message
|
||||||
|
|
||||||
|
**AC-9: Empty waypoints — `bbox_from_waypoints` raises `EmptyWaypointsError`**
|
||||||
|
Given a `FlightDto` with zero waypoints
|
||||||
|
When `bbox_from_waypoints(flight.waypoints, buffer_m=1000.0)` is called
|
||||||
|
Then `EmptyWaypointsError` is raised; the message instructs to re-plan in the Mission Planner UI
|
||||||
|
|
||||||
|
**AC-10: Bbox is horizontal-distance buffered (NOT degree-space)**
|
||||||
|
Given 4 waypoints at the corners of a 1 km × 1 km box centred on (50.0 N, 36.2 E)
|
||||||
|
When `bbox_from_waypoints(waypoints, buffer_m=1000.0)` is called
|
||||||
|
Then the returned bbox extends ~1 km outwards on all sides (NOT 1° outwards); the lat-degree extension is approximately `1000 / 111000 ≈ 0.009°`, the lon-degree extension at 50° N is approximately `1000 / (111000 * cos(50°)) ≈ 0.014°`. Asserted within 5% tolerance against `WgsConverter`.
|
||||||
|
|
||||||
|
**AC-11: Takeoff origin is `waypoints[0]` exactly (no rounding)**
|
||||||
|
Given a `FlightDto` with `waypoints[0] = WaypointDto(ordinal=0, lat_deg=50.000000001, lon_deg=36.2, alt_m=200.0, ...)`
|
||||||
|
When `takeoff_origin_from_flight(flight)` is called
|
||||||
|
Then the returned `LatLonAlt` is `LatLonAlt(50.000000001, 36.2, 200.0)` — no rounding, no projection
|
||||||
|
|
||||||
|
**AC-12: Conformance — `isinstance(impl, FlightsApiClient)` is `True`**
|
||||||
|
|
||||||
|
**AC-13: Online + Offline byte-identical output for same source**
|
||||||
|
Given the online happy path returns DTO `online_dto` and the same JSON written to disk loads as `offline_dto`
|
||||||
|
When both are compared
|
||||||
|
Then `online_dto == offline_dto`
|
||||||
|
|
||||||
|
**AC-14: Waypoint ordering — input shuffled but ordinals contiguous returns sorted tuple**
|
||||||
|
Given waypoints with ordinals `[2, 0, 1]` in input order
|
||||||
|
When `fetch_flight` or `load_flight_file` parses them
|
||||||
|
Then the returned `FlightDto.waypoints` is ordered `[0, 1, 2]`
|
||||||
|
|
||||||
|
**AC-15: Waypoint ordinal gap — raises `WaypointSchemaError`**
|
||||||
|
Given waypoints with ordinals `[0, 1, 3]` (gap)
|
||||||
|
When parsing
|
||||||
|
Then `WaypointSchemaError` is raised; gap is named in the message
|
||||||
|
|
||||||
|
**AC-16: Waypoint out-of-range lat raises `WaypointSchemaError`**
|
||||||
|
Given a waypoint with `lat_deg = 200.0`
|
||||||
|
When parsing
|
||||||
|
Then `WaypointSchemaError`; field name in the message
|
||||||
|
|
||||||
|
**AC-17: NFR — `auth_token` redaction**
|
||||||
|
Given any failure or success path
|
||||||
|
When inspecting captured stdout/stderr logs
|
||||||
|
Then the literal `auth_token` value never appears
|
||||||
|
|
||||||
|
**AC-18: NFR — online call timeout default 10 s**
|
||||||
|
Given a fake transport that delays 11 s
|
||||||
|
When `fetch_flight(timeout_s=10.0)` is called
|
||||||
|
Then `FlightsApiUnreachableError` is raised within ~10 s plus the single 1 s retry budget; ONE WARN log on the retry
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- Online fetch wall-clock on the happy path ≤ 1 s (single HTTP call, default 10 s timeout, network is the dominant factor).
|
||||||
|
- Offline load wall-clock ≤ 100 ms for a JSON file under 1 MB.
|
||||||
|
- `bbox_from_waypoints` is O(N) in waypoint count; ≤ 5 ms for N ≤ 1000.
|
||||||
|
|
||||||
|
**Compatibility**
|
||||||
|
- httpx per the project pin (already used by AZ-316). No new third-party deps unless pydantic is missing — verify against `requirements.txt`; if missing, add at the established version.
|
||||||
|
- TLS verify on by default; no `verify=False` anywhere in production code.
|
||||||
|
|
||||||
|
**Reliability**
|
||||||
|
- Auth token never logged.
|
||||||
|
- Online path retries at most once on transient 5xx or connect error; 401/403/404/schema errors are NEVER retried.
|
||||||
|
- Offline path is fully usable on an air-gapped operator workstation.
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
| AC Ref | What to Test | Required Outcome |
|
||||||
|
|--------|-------------|------------------|
|
||||||
|
| AC-1 | Happy 200 OK + 3-waypoint body | DTO; INFO log; no token in log |
|
||||||
|
| AC-2 | 404 response | `FlightNotFoundError`; no retry |
|
||||||
|
| AC-3 | 401 response | `FlightsApiAuthError`; no token in log |
|
||||||
|
| AC-4 | 503 then 200 | Success; ONE retry |
|
||||||
|
| AC-5 | 503 always | `FlightsApiUnreachableError` after one retry |
|
||||||
|
| AC-6 | Missing `lat` in response | `FlightsApiSchemaError`; field name |
|
||||||
|
| AC-7 | Offline well-formed JSON | DTO matches online shape |
|
||||||
|
| AC-8 | Offline missing file | `FlightFileNotFoundError` |
|
||||||
|
| AC-9 | Empty waypoints into `bbox_from_waypoints` | `EmptyWaypointsError` |
|
||||||
|
| AC-10 | Bbox buffer math at 50° N | Within 5% of horizontal-distance target |
|
||||||
|
| AC-11 | Takeoff origin pass-through | No rounding |
|
||||||
|
| AC-12 | Conformance | `isinstance` True |
|
||||||
|
| AC-13 | Online vs Offline DTO equality | `==` |
|
||||||
|
| AC-14 | Shuffled ordinals | Sorted output |
|
||||||
|
| AC-15 | Ordinal gap | `WaypointSchemaError` |
|
||||||
|
| AC-16 | lat=200 | `WaypointSchemaError` |
|
||||||
|
| AC-17 | Token redaction across all paths | Token absent from logs |
|
||||||
|
| AC-18 | Timeout + retry budget | Failure within bounded time |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- TLS verify on by default. Production composition root MUST NOT pass `verify=False` to httpx; tests use a separate test transport rather than disabling verification.
|
||||||
|
- `auth_token` field uses `pydantic.SecretStr` (or equivalent) and is NEVER `repr()`-logged.
|
||||||
|
- `bbox_from_waypoints` MUST use `WgsConverter` for the horizontal-distance buffer; naive `lat_deg ± 0.01` style buffering is rejected (fails AC-10 at high latitudes).
|
||||||
|
- The DTO shape MUST mirror `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. Any field added to the C# side requires a follow-up task; this task pins the schema at the current shape.
|
||||||
|
- No companion-side code in this task — the entire package is operator-workstation-only.
|
||||||
|
- Offline `--flight-file` accepts JSON only (orjson); YAML is rejected.
|
||||||
|
|
||||||
|
## Risks & Mitigation
|
||||||
|
|
||||||
|
**Risk 1: parent-suite `Flight` schema drifts silently**
|
||||||
|
- *Risk*: `suite/flights/` adds or renames a field, online fetches succeed but parse loosely and miss the new field.
|
||||||
|
- *Mitigation*: pydantic schema is strict (`extra="forbid"`); AC-6 covers the schema-violation path; a CI tripwire compares the C# entity to the Python DTO once per build.
|
||||||
|
|
||||||
|
**Risk 2: auth_token leaks via `repr()` of an exception**
|
||||||
|
- *Risk*: a caller logs `repr(exc)` and the wrapped httpx response carries the token in headers.
|
||||||
|
- *Mitigation*: `FlightsApiAuthError`'s `__str__` and `__repr__` explicitly redact; AC-3 + AC-17 cover.
|
||||||
|
|
||||||
|
**Risk 3: bbox too tight at high latitudes due to degree-space buffer**
|
||||||
|
- *Risk*: a naive `lat ± 0.01` buffer is ~1.5× too narrow at 60° N and tiles near the route are missing.
|
||||||
|
- *Mitigation*: AC-10 + AC-NFR enforce horizontal-distance buffer via `WgsConverter`; reviewed in code review.
|
||||||
|
|
||||||
|
**Risk 4: offline JSON drifts from the parent-suite serialization**
|
||||||
|
- *Risk*: an operator hand-edits a JSON export and breaks ordinals or waypoint shape.
|
||||||
|
- *Mitigation*: AC-15 / AC-16 cover schema validation; the error message references the exact field so the operator can fix it.
|
||||||
|
|
||||||
|
## Runtime Completeness
|
||||||
|
|
||||||
|
- **Named capability**: read-only Flight resolution for C12 cache provisioning (ADR-010).
|
||||||
|
- **Production code that must exist**: real `HttpxFlightsApiClient` against the parent-suite `flights` REST service with TLS + auth, real `FlightsFileLoader` for the offline path, real `WgsConverter`-backed bbox buffer.
|
||||||
|
- **Allowed external stubs**: tests use httpx `MockTransport` and a fake clock; production wiring uses real httpx + real WgsConverter.
|
||||||
|
- **Unacceptable substitutes**: requests/urllib3 instead of httpx (project pin says httpx); naive degree-space bbox buffer; logging the auth_token "just for debugging"; verifying TLS off in production code.
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest
|
||||||
|
|
||||||
|
**Task**: AZ-490_c5_set_takeoff_origin
|
||||||
|
**Name**: C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest
|
||||||
|
**Description**: Extend `StateEstimator` (Protocol + both concrete impls — `GtsamIsam2StateEstimator` and `EskfStateEstimator`) with a new pre-takeoff entrypoint `set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None` that seeds the cold-start prior to the first frame. The composition root calls this method during F2 (Takeoff load) when the C10 ManifestVerifier reports a valid `flight.takeoff_origin`. With operator origin set, the FC-EKF cold-start path becomes the secondary fallback (ADR-010). The method also makes the spoof-promotion gate (AZ-385) consult a third bounded-delta clause: mid-flight FC GPS samples within 200 m of the current smoother estimate may be admitted as a soft constraint; samples > 200 m off are rejected and emit an FDR `c5.gps_bounded_delta.reject` record. The 200 m threshold is config-driven (`spoof_promotion_bounded_delta_m`, default 200.0). This task delivers the C5-side contract + the FDR record kind + the unit tests covering primary/secondary/conflict paths; downstream wiring into the composition root and the F2 sequence is the consumer's responsibility (AZ-381 owner already plumbs it).
|
||||||
|
**Complexity**: 3 points
|
||||||
|
**Dependencies**: AZ-381 (Protocol + DTOs + factory), AZ-383 (factor adds), AZ-384 (marginals + outputs), AZ-385 (source-label gate; bounded-delta is a new clause inside its state machine), AZ-386 (ESKF baseline must honour the same entrypoint), AZ-272 (FdrRecord Schema — new `c5.cold_start_origin.set` and `c5.gps_bounded_delta.{accept,reject}` kinds), AZ-273 (FdrClient), AZ-279 (WgsConverter for the bounded-delta Vincenty distance), AZ-269 (config), AZ-266 (logging), AZ-263 (initial structure)
|
||||||
|
**Component**: c5_state (epic AZ-260 / E-C5)
|
||||||
|
**Tracker**: AZ-490
|
||||||
|
**Epic**: AZ-260 (E-C5)
|
||||||
|
|
||||||
|
### Document Dependencies
|
||||||
|
|
||||||
|
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md` § Invariants 11–12, § Config schema, § Test expectations.
|
||||||
|
- `_docs/02_document/components/07_c5_state/description.md` § State management (cold-start ladder), § Spoof-promotion gate (3rd clause).
|
||||||
|
- `_docs/02_document/architecture.md` ADR-010 (operator origin primary), Principle #11 (amended).
|
||||||
|
- `_docs/02_document/system-flows.md` F2 (Takeoff load), F7 (spoof gate).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today, C5's `StateEstimator` Protocol has only one cold-start trust anchor: the FC EKF GPS snapshot consumed at first frame. ADR-010 makes the operator-planned mission the primary anchor and the FC EKF the secondary fallback — but the C5 Protocol carries no method to accept an external operator origin, so the composition root has nowhere to deliver the Manifest-resolved value.
|
||||||
|
|
||||||
|
Concretely:
|
||||||
|
|
||||||
|
- The C5 Protocol exposes `add_vio`, `add_pose_anchor`, `add_fc_imu`, `query` — none of which fit "pre-takeoff, set the absolute reference frame". Trying to overload `add_pose_anchor` would conflate "constant absolute origin from a trusted offline source" with "noisy per-frame satellite anchor"; the noise model, gating, and FDR record kind are all different.
|
||||||
|
- AZ-385's spoof-promotion gate has two clauses (consistency-with-VPR-anchors + dwell-time). ADR-010 amends Principle #11 with a third clause: an FC GPS sample within `spoof_promotion_bounded_delta_m` of the current smoother estimate is admitted as a soft constraint, but samples outside that ring are rejected and counted against the spoof-promotion gate. Without this third clause, mid-flight FC GPS is either fully trusted or fully ignored — losing a legitimate fallback signal when GPS recovers.
|
||||||
|
- The cold-start ladder is undocumented in the running code: operator origin should win when both are available, FC EKF should win when operator origin is absent, and the system should refuse takeoff when neither is available — but no method captures these states.
|
||||||
|
- Two concrete estimators (iSAM2 + ESKF) need to honour the same entrypoint with the same semantics so the composition root can switch strategies without re-wiring F2.
|
||||||
|
|
||||||
|
This task lands the C5-side contract, the FDR records, and the spoof-gate amendment. It does NOT touch the C10 Manifest parsing (AZ-324), the C12 Flight resolution (AZ-489), or the composition-root F2 wiring (AZ-381 owner sequences that as a follow-up commit).
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- **`StateEstimator` Protocol** (`src/gps_denied_onboard/components/c5_state/interface.py`):
|
||||||
|
- New method `set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None`.
|
||||||
|
- Contract: idempotent if called twice with identical args; raises `EstimatorAlreadyStartedError` if called after the first `add_vio` / `add_pose_anchor`; raises `EstimatorConfigError` on negative sigmas or on `LatLonAlt` outside WGS-84 bounds.
|
||||||
|
- **`GtsamIsam2StateEstimator`** (`src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py`):
|
||||||
|
- `set_takeoff_origin(...)` sets the local-ENU origin via `WgsConverter`, seeds the iSAM2 prior factor at `Pose3(Rot3.Identity(), Point3(0,0,0))` with a horizontal sigma of `sigma_horiz_m` and a vertical sigma of `sigma_vert_m`, and emits an FDR `c5.cold_start_origin.set` record (`source="manifest"`) with the origin lat/lon/alt.
|
||||||
|
- Internal `_origin_source: Literal["manifest", "fc_ekf", None]` field tracks the cold-start path; first `add_vio` call without an origin set is allowed (FC EKF path stays); first `add_vio` call with an origin set fixes the cold-start ladder to "manifest".
|
||||||
|
- **`EskfStateEstimator`** (`src/gps_denied_onboard/components/c5_state/eskf_baseline.py`):
|
||||||
|
- Same method, same semantics; for ESKF the origin is set on the local-ENU converter; the state's nominal position prior is set to `(0,0,0)` with covariance `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`.
|
||||||
|
- **Spoof-promotion gate amendment** (`src/gps_denied_onboard/components/c5_state/source_label_state_machine.py` — owned by AZ-385, this task patches it with one new clause):
|
||||||
|
- When the gate processes an incoming FC GPS sample, it computes `vincenty(current_smoother_latlon, sample_latlon)` via `WgsConverter`.
|
||||||
|
- If `distance ≤ spoof_promotion_bounded_delta_m`: sample is admitted as a `BOUNDED_DELTA_SOFT` source label; FDR `c5.gps_bounded_delta.accept` is emitted.
|
||||||
|
- If `distance > spoof_promotion_bounded_delta_m`: sample is rejected; FDR `c5.gps_bounded_delta.reject` is emitted naming the sample, the current estimate, and the computed distance; the rejection counts against the existing dwell-time clause.
|
||||||
|
- **Config schema additions** (`src/gps_denied_onboard/config/c5_state.py` via AZ-269's loader):
|
||||||
|
- `spoof_promotion_bounded_delta_m: float` (default `200.0`).
|
||||||
|
- `default_takeoff_origin_sigma_horiz_m: float` (default `5.0`) — used when the Manifest does not carry an explicit sigma.
|
||||||
|
- `default_takeoff_origin_sigma_vert_m: float` (default `10.0`).
|
||||||
|
- **FDR record kinds** (`src/gps_denied_onboard/fdr/record_schema.py` via AZ-272 schema extension):
|
||||||
|
- `c5.cold_start_origin.set` — `{source: "manifest" | "fc_ekf", lat_deg, lon_deg, alt_m, sigma_horiz_m, sigma_vert_m}`.
|
||||||
|
- `c5.cold_start_origin.unavailable` — emitted when neither anchor is available; carries a takeoff-abort reason code.
|
||||||
|
- `c5.gps_bounded_delta.accept` — `{sample_lat, sample_lon, smoother_lat, smoother_lon, distance_m, threshold_m}`.
|
||||||
|
- `c5.gps_bounded_delta.reject` — same shape as `accept`.
|
||||||
|
- **Logging**:
|
||||||
|
- INFO on every `set_takeoff_origin` call (`kind="c5.cold_start_origin.set"`).
|
||||||
|
- WARN on every bounded-delta reject (`kind="c5.gps_bounded_delta.reject"`).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Included
|
||||||
|
|
||||||
|
- Protocol method addition + both concrete impls (iSAM2 + ESKF).
|
||||||
|
- WgsConverter integration for ENU origin + Vincenty distance in bounded-delta gate.
|
||||||
|
- Spoof-gate's third clause + FDR record emission.
|
||||||
|
- Config schema entries for the three new keys.
|
||||||
|
- Error classes: `EstimatorAlreadyStartedError`, `EstimatorConfigError`.
|
||||||
|
- Unit tests covering every AC: idempotency, post-start rejection, sigma validation, lat/lon bounds, bounded-delta accept/reject, source-label transition, FDR emission shape, both estimator impls.
|
||||||
|
|
||||||
|
### Excluded
|
||||||
|
|
||||||
|
- C10 Manifest schema changes (owned by AZ-323 / AZ-324).
|
||||||
|
- C12 Flight resolution (owned by AZ-489).
|
||||||
|
- Composition-root F2 wiring of `manifest.takeoff_origin → estimator.set_takeoff_origin(...)` (owned by AZ-381 owner as a follow-up commit, NOT this task).
|
||||||
|
- Operator origin "refresh" mid-flight (out of scope — the cold-start anchor is set once at takeoff and not revised).
|
||||||
|
- Changes to the AZ-385 source-label state-machine's first two clauses (consistency + dwell-time); only the third clause is added here.
|
||||||
|
- ESKF deep-rewrite (the ESKF stays as the AZ-386 baseline; only `set_takeoff_origin` + bounded-delta admission are added).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
**AC-1: Protocol-conformance — both impls expose `set_takeoff_origin`**
|
||||||
|
Given `from c5_state import GtsamIsam2StateEstimator, EskfStateEstimator, StateEstimator`
|
||||||
|
When `isinstance(impl, StateEstimator)` is checked on each
|
||||||
|
Then both return `True`; both have a callable `set_takeoff_origin` with the documented signature.
|
||||||
|
|
||||||
|
**AC-2: Happy path — origin set before first VIO seeds the smoother prior (iSAM2)**
|
||||||
|
Given a fresh `GtsamIsam2StateEstimator`
|
||||||
|
When `set_takeoff_origin(LatLonAlt(50.0, 36.2, 200.0), sigma_horiz_m=5.0, sigma_vert_m=10.0)` is called
|
||||||
|
Then the iSAM2 graph has exactly one prior factor at `Pose3.Identity()` with sigma diag matching `[deg2rad(5°)*3, deg2rad(5°)*3, deg2rad(5°)*3, 5.0, 5.0, 10.0]` (rotation sigma is the iSAM2 default Identity prior; translation sigmas come from the call); ONE FDR record `c5.cold_start_origin.set` is emitted with `source="manifest"`; ONE INFO log.
|
||||||
|
|
||||||
|
**AC-3: Happy path — origin set before first VIO seeds the ESKF state prior**
|
||||||
|
Given a fresh `EskfStateEstimator`
|
||||||
|
When `set_takeoff_origin(LatLonAlt(50.0, 36.2, 200.0), sigma_horiz_m=5.0, sigma_vert_m=10.0)` is called
|
||||||
|
Then the ESKF's `P` matrix's position block is `diag(25.0, 25.0, 100.0)`; the ENU origin is set to the call's `LatLonAlt`; ONE FDR record + ONE INFO log as in AC-2.
|
||||||
|
|
||||||
|
**AC-4: Idempotent — calling twice with identical args is a no-op**
|
||||||
|
Given the estimator after a first `set_takeoff_origin(A, s, s_v)` call
|
||||||
|
When `set_takeoff_origin(A, s, s_v)` is called again with identical args
|
||||||
|
Then no second FDR record is emitted; no second prior factor is added; no exception raised.
|
||||||
|
|
||||||
|
**AC-5: Conflict — calling twice with different args raises `EstimatorConfigError`**
|
||||||
|
Given the estimator after `set_takeoff_origin(A, ...)`
|
||||||
|
When `set_takeoff_origin(B, ...)` is called with `A != B`
|
||||||
|
Then `EstimatorConfigError` is raised; message names both values; the estimator state is unchanged.
|
||||||
|
|
||||||
|
**AC-6: Late call — `set_takeoff_origin` after first `add_vio` raises `EstimatorAlreadyStartedError`**
|
||||||
|
Given the estimator after one `add_vio(...)` call
|
||||||
|
When `set_takeoff_origin(...)` is called
|
||||||
|
Then `EstimatorAlreadyStartedError` is raised; the estimator state is unchanged.
|
||||||
|
|
||||||
|
**AC-7: Bounds — invalid `LatLonAlt` raises `EstimatorConfigError`**
|
||||||
|
Given `LatLonAlt(lat_deg=95.0, ...)` (out of WGS-84 bounds)
|
||||||
|
When `set_takeoff_origin(invalid_origin, ...)` is called
|
||||||
|
Then `EstimatorConfigError` is raised; message names the violated bound.
|
||||||
|
|
||||||
|
**AC-8: Negative sigma raises `EstimatorConfigError`**
|
||||||
|
Given `sigma_horiz_m=-5.0`
|
||||||
|
When `set_takeoff_origin(...)` is called
|
||||||
|
Then `EstimatorConfigError`; message names the violated invariant.
|
||||||
|
|
||||||
|
**AC-9: Bounded-delta accept — incoming GPS within 200 m of smoother estimate is admitted**
|
||||||
|
Given a running smoother with current estimate at `LatLonAlt(50.000, 36.200, 200.0)`
|
||||||
|
When a `GpsSample(LatLonAlt(50.0008, 36.2008, 200.0))` arrives via the existing AZ-391 inbound path (distance ≈ 110 m at 50° N)
|
||||||
|
Then the gate admits the sample with source label `BOUNDED_DELTA_SOFT`; ONE FDR record `c5.gps_bounded_delta.accept` is emitted; the sample contributes to the iSAM2 graph as a soft factor with sigma per the config; the source-label state machine's first-two-clause behaviour is unchanged.
|
||||||
|
|
||||||
|
**AC-10: Bounded-delta reject — incoming GPS > 200 m off is rejected**
|
||||||
|
Given the same smoother as AC-9
|
||||||
|
When a `GpsSample(LatLonAlt(50.005, 36.205, 200.0))` arrives (distance ≈ 700 m)
|
||||||
|
Then the gate rejects the sample; ONE FDR `c5.gps_bounded_delta.reject` record is emitted naming sample, smoother estimate, and distance; the sample is NOT added to the graph; the rejection increments the source-label state machine's dwell-time counter.
|
||||||
|
|
||||||
|
**AC-11: Threshold is config-driven — setting `spoof_promotion_bounded_delta_m=500.0` admits AC-10's sample**
|
||||||
|
Given the config override `spoof_promotion_bounded_delta_m=500.0` is in effect (NOTE: 500 m is still < AC-10's 700 m, so the assertion is: the original AC-10 reject still happens, but with a 500 m threshold a 300 m offset now passes)
|
||||||
|
Re-spec to:
|
||||||
|
Given the config override `spoof_promotion_bounded_delta_m=1000.0` is in effect
|
||||||
|
When the AC-10 sample arrives
|
||||||
|
Then it is admitted as `BOUNDED_DELTA_SOFT` (it now sits within the relaxed ring).
|
||||||
|
|
||||||
|
**AC-12: FDR record kinds are registered in the AZ-272 schema**
|
||||||
|
Given the AZ-272 schema after this task
|
||||||
|
When `kind="c5.cold_start_origin.set"`, `kind="c5.cold_start_origin.unavailable"`, `kind="c5.gps_bounded_delta.accept"`, `kind="c5.gps_bounded_delta.reject"` are encoded
|
||||||
|
Then each round-trips through serialization without raising; the schema contract test (AZ-268) covers all four.
|
||||||
|
|
||||||
|
**AC-13: No-op when no origin is set — FC EKF cold-start path unchanged**
|
||||||
|
Given a fresh estimator
|
||||||
|
When `add_vio(...)` is called WITHOUT a prior `set_takeoff_origin` call
|
||||||
|
Then the estimator falls back to the legacy FC EKF cold-start path; the FDR record `c5.cold_start_origin.set` is emitted with `source="fc_ekf"` exactly once at first frame (this is the legacy path, just newly logged); no new behaviour beyond the FDR record kind.
|
||||||
|
|
||||||
|
**AC-14: Source-label state machine remains stable for AZ-385's first two clauses**
|
||||||
|
Given the bounded-delta clause's introduction
|
||||||
|
When the AZ-385 acceptance tests run unmodified
|
||||||
|
Then they pass — the bounded-delta clause is additive, not replacing the existing two.
|
||||||
|
|
||||||
|
**AC-15: Vincenty distance is computed via `WgsConverter` (not naive haversine on the equirectangular projection)**
|
||||||
|
Given a smoother estimate at high latitude (`60° N`) and a sample 200 m to the east
|
||||||
|
When the gate computes the distance
|
||||||
|
Then it uses `WgsConverter.vincenty_distance` and matches the documented ground-truth distance within ±0.5 m.
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- `set_takeoff_origin` wall-clock ≤ 5 ms (single prior factor insertion + one FDR emission).
|
||||||
|
- Bounded-delta check on every inbound GPS sample ≤ 1 ms (single Vincenty call + threshold compare).
|
||||||
|
|
||||||
|
**Reliability**
|
||||||
|
- Idempotency at AC-4 prevents accidental double-seeding on composition-root retries.
|
||||||
|
- Late-call rejection at AC-6 prevents bricking an in-flight smoother by mistake.
|
||||||
|
- All four FDR record kinds are part of the AZ-272 schema; AZ-268's contract test gates schema drift.
|
||||||
|
|
||||||
|
**Compatibility**
|
||||||
|
- Both estimator impls (iSAM2 + ESKF) honour the same signature so the composition root can switch strategies without re-wiring F2.
|
||||||
|
- The legacy FC EKF cold-start path stays as the secondary fallback (AC-13); existing AZ-419's old AC-3 still passes against the FC EKF path when no Manifest origin is present.
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
| AC Ref | What to Test | Required Outcome |
|
||||||
|
|--------|-------------|------------------|
|
||||||
|
| AC-1 | Protocol conformance both impls | `isinstance` True |
|
||||||
|
| AC-2 | iSAM2 set-origin seeds prior | Prior factor + sigmas; FDR record |
|
||||||
|
| AC-3 | ESKF set-origin seeds P | P block matches; ENU origin set; FDR record |
|
||||||
|
| AC-4 | Idempotent double-call | No second FDR; no exception |
|
||||||
|
| AC-5 | Conflict double-call | `EstimatorConfigError`; names both |
|
||||||
|
| AC-6 | Late call after add_vio | `EstimatorAlreadyStartedError` |
|
||||||
|
| AC-7 | Out-of-bounds lat | `EstimatorConfigError` |
|
||||||
|
| AC-8 | Negative sigma | `EstimatorConfigError` |
|
||||||
|
| AC-9 | Bounded-delta accept | `BOUNDED_DELTA_SOFT` label + FDR |
|
||||||
|
| AC-10 | Bounded-delta reject | Sample dropped + FDR reject |
|
||||||
|
| AC-11 | Threshold config override | AC-10 sample admitted at relaxed threshold |
|
||||||
|
| AC-12 | FDR schema round-trip | All 4 kinds serialise; AZ-268 covers |
|
||||||
|
| AC-13 | No origin → FC EKF path | Legacy path + new FDR record `source="fc_ekf"` |
|
||||||
|
| AC-14 | AZ-385's first 2 clauses unchanged | All AZ-385 tests pass unmodified |
|
||||||
|
| AC-15 | Vincenty at 60° N | Within ±0.5 m of ground truth |
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- `set_takeoff_origin` is the ONLY supported pre-takeoff origin entrypoint; do NOT add `set_takeoff_origin_from_gps(...)` or similar convenience overloads — the FC EKF path stays purely inside the legacy first-frame logic.
|
||||||
|
- The bounded-delta clause is the THIRD clause of the AZ-385 source-label state machine; do NOT replace AZ-385's existing consistency + dwell-time logic — just add the new clause and its FDR records.
|
||||||
|
- No clock or `datetime.now()` calls in this code path — pass a `Clock` Protocol per the project's established pattern (used by AZ-273, AZ-382).
|
||||||
|
- The 200 m threshold is configurable but NOT operator-tunable from the GCS — it's a deploy-time config.
|
||||||
|
- No "auto-correct origin" or "average origins" logic — operator origin set once at takeoff or not at all.
|
||||||
|
|
||||||
|
## Risks & Mitigation
|
||||||
|
|
||||||
|
**Risk 1: Operator origin from a stale/wrong flight plan poisons the cold start**
|
||||||
|
- *Risk*: Mission Planner export drift, manual file edits, or a wrong `--flight-id` flag selects the wrong route.
|
||||||
|
- *Mitigation*: AZ-323's Manifest carries `flight_id` + `takeoff_origin`, both hashed into `manifest_hash`; AZ-324 validates origin is inside the bbox; this task validates `LatLonAlt` bounds (AC-7) but does NOT re-validate against the Manifest's bbox — the Manifest-level check is sufficient. AC-5's conflict-on-double-call surfaces drift if the composition root somehow re-calls with a different value.
|
||||||
|
|
||||||
|
**Risk 2: Bounded-delta gate admits spoofed GPS that happens to be < 200 m off**
|
||||||
|
- *Risk*: A sophisticated spoofer reads the C5's smoother output and emits GPS within the ring.
|
||||||
|
- *Mitigation*: Bounded-delta admission is `BOUNDED_DELTA_SOFT`, NOT `SATELLITE_ANCHORED`; AZ-385's other two clauses (consistency-with-VPR-anchors + dwell-time) remain authoritative for the final spoof-promotion decision. The bounded-delta channel is a soft constraint, not a hard reference.
|
||||||
|
|
||||||
|
**Risk 3: Composition root forgets to call `set_takeoff_origin` even though the Manifest carries one**
|
||||||
|
- *Risk*: F2 wiring drift between AZ-381 and AZ-324 — the Manifest has the origin, but the estimator doesn't see it.
|
||||||
|
- *Mitigation*: AZ-419's updated AC-1 / AC-4 (operator origin test cases) exercise the full F2 chain end-to-end; failure would be visible there. AC-13 here proves the no-origin path is still functional so this task does not break the legacy fallback during the transition.
|
||||||
|
|
||||||
|
## Runtime Completeness
|
||||||
|
|
||||||
|
- **Named capability**: pre-takeoff operator origin acceptance + mid-flight bounded-delta GPS gate (ADR-010 Principle #11 amended).
|
||||||
|
- **Production code that must exist**: real `set_takeoff_origin` on both `GtsamIsam2StateEstimator` and `EskfStateEstimator`; real `WgsConverter` for ENU origin + Vincenty distance; real FDR record emission with the four new kinds; real config wiring via AZ-269.
|
||||||
|
- **Allowed external stubs**: tests use `FakeFdrSink` (AZ-275), a fake `Clock`, and a fixed `WgsConverter` instance with a known ENU origin.
|
||||||
|
- **Unacceptable substitutes**: a "set-origin" convenience that just sets a class attribute without seeding the prior factor (would silently no-op); haversine instead of Vincenty (loses precision at high lat → AC-15 fails); skipping the FDR records "because they're operational telemetry" (the schema contract test AZ-268 would still flag them as missing).
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Task**: AZ-323_c10_manifest_builder
|
**Task**: AZ-323_c10_manifest_builder
|
||||||
**Name**: C10 Manifest Builder
|
**Name**: C10 Manifest Builder
|
||||||
**Description**: Implement `ManifestBuilder`, the C10-internal phase that produces the signed cache Manifest covering EVERY shipped artifact (engines, FAISS index, calibration JSON, all tile hashes from C6) plus the build-identity tuple `(model_ids, calibration_sha256, sorted_tile_hashes, sector_class, bbox, zoom_levels)` whose canonical hash is `manifest_hash` — the D-C10-1 idempotence key. Serializes the Manifest as canonical JSON (sorted keys, no whitespace) at `cache_root/Manifest.json`, computes its own SHA-256 sidecar via AZ-280, and writes a detached Ed25519 signature at `cache_root/Manifest.json.sig` using the operator's signing key from `key_path`. Refuses to sign with a non-operator key when `config.c10.signing_mode = "operator"` (C10-ST-01). Emits the `signing_public_key_fingerprint` into the Manifest itself so verifiers can pin the trust root.
|
**Description**: Implement `ManifestBuilder`, the C10-internal phase that produces the signed cache Manifest covering EVERY shipped artifact (engines, FAISS index, calibration JSON, all tile hashes from C6) plus the build-identity tuple `(model_ids, calibration_sha256, sorted_tile_hashes, sector_class, bbox, zoom_levels, takeoff_origin, flight_id)` whose canonical hash is `manifest_hash` — the D-C10-1 idempotence key. The `takeoff_origin` (`LatLonAlt`) and `flight_id` (`UUID`) are supplied by C12 from `Flight.waypoints[0]` via the `FlightsApiClient` (ADR-010, AZ-489); both are baked into the Manifest body **and** included in the manifest-hash so re-planning the flight produces a new cache identity. Serializes the Manifest as canonical JSON (sorted keys, no whitespace) at `cache_root/Manifest.json`, computes its own SHA-256 sidecar via AZ-280, and writes a detached Ed25519 signature at `cache_root/Manifest.json.sig` using the operator's signing key from `key_path`. Refuses to sign with a non-operator key when `config.c10.signing_mode = "operator"` (C10-ST-01). Emits the `signing_public_key_fingerprint` into the Manifest itself so verifiers can pin the trust root.
|
||||||
**Complexity**: 3 points
|
**Complexity**: 3 points
|
||||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema, AZ-303_c6_storage_interfaces
|
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema, AZ-303_c6_storage_interfaces
|
||||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||||
@@ -34,7 +34,7 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
|||||||
- Constructor: `__init__(self, *, sidecar: Sha256Sidecar, signer: ManifestSigner, tile_metadata_store: TileMetadataStore, logger: Logger, clock: Clock, config: C10ManifestConfig)`.
|
- Constructor: `__init__(self, *, sidecar: Sha256Sidecar, signer: ManifestSigner, tile_metadata_store: TileMetadataStore, logger: Logger, clock: Clock, config: C10ManifestConfig)`.
|
||||||
- `C10ManifestConfig` (`@dataclass(frozen=True)`): `signing_mode: enum {operator, dev}`, `allowed_operator_fingerprints: tuple[str, ...]`, `schema_version: str = "1.0"`.
|
- `C10ManifestConfig` (`@dataclass(frozen=True)`): `signing_mode: enum {operator, dev}`, `allowed_operator_fingerprints: tuple[str, ...]`, `schema_version: str = "1.0"`.
|
||||||
- Public method: `build_manifest(input: ManifestBuildInput) -> ManifestArtifact`.
|
- Public method: `build_manifest(input: ManifestBuildInput) -> ManifestArtifact`.
|
||||||
- `ManifestBuildInput` (`@dataclass(frozen=True)`): `cache_root: Path`, `bbox: Bbox`, `zoom_levels: tuple[int, ...]`, `sector_class: SectorClassification`, `engine_entries: tuple[EngineCacheEntry, ...]`, `descriptor_index_path: Path`, `calibration_path: Path`, `key_path: Path`.
|
- `ManifestBuildInput` (`@dataclass(frozen=True)`): `cache_root: Path`, `bbox: Bbox`, `zoom_levels: tuple[int, ...]`, `sector_class: SectorClassification`, `engine_entries: tuple[EngineCacheEntry, ...]`, `descriptor_index_path: Path`, `calibration_path: Path`, `key_path: Path`, `takeoff_origin: LatLonAlt | None = None` (ADR-010 / AZ-489 — when set, baked into Manifest + hash), `flight_id: UUID | None = None` (ADR-010 — pass-through provenance).
|
||||||
- `ManifestArtifact` (`@dataclass(frozen=True)`): `manifest_path: Path`, `signature_path: Path`, `manifest_hash: str`, `signing_public_key_fingerprint: str`, `total_artifacts_listed: int`.
|
- `ManifestArtifact` (`@dataclass(frozen=True)`): `manifest_path: Path`, `signature_path: Path`, `manifest_hash: str`, `signing_public_key_fingerprint: str`, `total_artifacts_listed: int`.
|
||||||
- A `ManifestSigner` Protocol at `src/gps_denied_onboard/components/c10_provisioning/interface.py`:
|
- A `ManifestSigner` Protocol at `src/gps_denied_onboard/components/c10_provisioning/interface.py`:
|
||||||
```python
|
```python
|
||||||
@@ -54,10 +54,10 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
|||||||
- For descriptor index: call `sidecar.read_sidecar(input.descriptor_index_path)` → expect a 64-char hex digest.
|
- For descriptor index: call `sidecar.read_sidecar(input.descriptor_index_path)` → expect a 64-char hex digest.
|
||||||
- For calibration JSON: `sha256_hex(open(calibration_path, 'rb').read())` — calibration is small (KB).
|
- For calibration JSON: `sha256_hex(open(calibration_path, 'rb').read())` — calibration is small (KB).
|
||||||
- For tiles: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)` → list of `TileMetadata` with `sha256_hex` field (set by AZ-316). Sort by `(zoom, lat, lon, source)` for determinism. Compute `tiles_coverage_sha256 = sha256(b"\n".join(f"{t.tile_id}:{t.sha256_hex}".encode() for t in sorted_tiles))`.
|
- For tiles: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)` → list of `TileMetadata` with `sha256_hex` field (set by AZ-316). Sort by `(zoom, lat, lon, source)` for determinism. Compute `tiles_coverage_sha256 = sha256(b"\n".join(f"{t.tile_id}:{t.sha256_hex}".encode() for t in sorted_tiles))`.
|
||||||
5. Build the canonical Manifest dict:
|
5. Build the canonical Manifest dict (ADR-010 adds `flight.takeoff_origin` + `flight.flight_id` blocks when supplied):
|
||||||
```
|
```
|
||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.1",
|
||||||
"build": {
|
"build": {
|
||||||
"bbox": {...},
|
"bbox": {...},
|
||||||
"zoom_levels": [16, 17, 18],
|
"zoom_levels": [16, 17, 18],
|
||||||
@@ -65,6 +65,14 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
|||||||
"built_at": "2026-05-10T12:00:00Z",
|
"built_at": "2026-05-10T12:00:00Z",
|
||||||
"manifest_hash": "<sha256-hex>"
|
"manifest_hash": "<sha256-hex>"
|
||||||
},
|
},
|
||||||
|
"flight": {
|
||||||
|
"flight_id": "<uuid>", // null when ManifestBuildInput.flight_id is None
|
||||||
|
"takeoff_origin": { // omitted when ManifestBuildInput.takeoff_origin is None
|
||||||
|
"lat_deg": <float>,
|
||||||
|
"lon_deg": <float>,
|
||||||
|
"alt_m": <float>
|
||||||
|
}
|
||||||
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
"engines": [{"path": "engines/dinov2_vpr_sm87_jp62_trt103_fp16.engine", "sha256": "<hex>"}, ...],
|
"engines": [{"path": "engines/dinov2_vpr_sm87_jp62_trt103_fp16.engine", "sha256": "<hex>"}, ...],
|
||||||
"descriptor_index": {"path": "descriptors/corpus.index", "sha256": "<hex>"},
|
"descriptor_index": {"path": "descriptors/corpus.index", "sha256": "<hex>"},
|
||||||
@@ -74,7 +82,7 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
|||||||
"signing_public_key_fingerprint": "<hex>"
|
"signing_public_key_fingerprint": "<hex>"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
6. Compute `manifest_hash` as `sha256(canonical_json(build_identity_tuple))` where `build_identity_tuple = sorted({model_ids, calibration_sha256, tiles_coverage_sha256, sector_class, bbox, zoom_levels})`. This is the D-C10-1 idempotence key. Insert into the Manifest dict at `build.manifest_hash` AFTER computation.
|
6. Compute `manifest_hash` as `sha256(canonical_json(build_identity_tuple))` where `build_identity_tuple = sorted({model_ids, calibration_sha256, tiles_coverage_sha256, sector_class, bbox, zoom_levels, takeoff_origin_tuple_or_none, flight_id_or_none})`. The takeoff origin is serialised as `(lat_deg, lon_deg, alt_m)` rounded to 9 decimal places (sub-millimetre, deterministic). This is the D-C10-1 idempotence key. Insert into the Manifest dict at `build.manifest_hash` AFTER computation. **Two builds with identical inputs but different `takeoff_origin` produce different `manifest_hash` values; this is the contract that lets `ManifestVerifier` reject a re-planned route at boot (AZ-324, MV-INV-8).**
|
||||||
7. Serialize the Manifest dict as canonical JSON: `orjson.dumps(manifest, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2).decode()`. Append a trailing newline.
|
7. Serialize the Manifest dict as canonical JSON: `orjson.dumps(manifest, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2).decode()`. Append a trailing newline.
|
||||||
8. Atomic-write the JSON via `sidecar.write_with_sidecar(cache_root / "Manifest.json", canonical_json_bytes)` — produces `Manifest.json` + `Manifest.json.sha256` (the latter is the Manifest's OWN sha256, used by T4).
|
8. Atomic-write the JSON via `sidecar.write_with_sidecar(cache_root / "Manifest.json", canonical_json_bytes)` — produces `Manifest.json` + `Manifest.json.sha256` (the latter is the Manifest's OWN sha256, used by T4).
|
||||||
9. Sign the canonical JSON bytes: `signature_bytes = signer.sign(key, canonical_json_bytes)` (raw Ed25519 signature, 64 bytes).
|
9. Sign the canonical JSON bytes: `signature_bytes = signer.sign(key, canonical_json_bytes)` (raw Ed25519 signature, 64 bytes).
|
||||||
@@ -168,6 +176,26 @@ Given an input with N engines + 1 index + 1 calibration + tiles_coverage
|
|||||||
When `ManifestArtifact.total_artifacts_listed` is inspected
|
When `ManifestArtifact.total_artifacts_listed` is inspected
|
||||||
Then it equals `N + 3` (engines + index + calibration + tiles_coverage); does NOT count the Manifest itself or the signature
|
Then it equals `N + 3` (engines + index + calibration + tiles_coverage); does NOT count the Manifest itself or the signature
|
||||||
|
|
||||||
|
**AC-13: `takeoff_origin` baked into Manifest body when supplied (ADR-010 / AZ-489)**
|
||||||
|
Given a `ManifestBuildInput` with `takeoff_origin = LatLonAlt(50.0, 36.2, 200.0)` and `flight_id = some_uuid`
|
||||||
|
When `build_manifest` is called
|
||||||
|
Then the Manifest body contains a `flight` block with `flight_id` and `takeoff_origin` (`lat_deg=50.0`, `lon_deg=36.2`, `alt_m=200.0`); ZERO `built_at`-style timestamp inside `takeoff_origin`
|
||||||
|
|
||||||
|
**AC-14: `takeoff_origin` absent from Manifest body when not supplied**
|
||||||
|
Given a `ManifestBuildInput` with `takeoff_origin = None` and `flight_id = None`
|
||||||
|
When `build_manifest` is called
|
||||||
|
Then the Manifest body has the `flight` block with `flight_id: null` and NO `takeoff_origin` key (use absence, not `null`, so AZ-324 can detect "field never set" vs "field invalid")
|
||||||
|
|
||||||
|
**AC-15: `manifest_hash` changes when only `takeoff_origin` differs**
|
||||||
|
Given two `ManifestBuildInput`s identical except `takeoff_origin = A` vs `takeoff_origin = B` (B != A by ≥ 1 mm)
|
||||||
|
When `build_manifest` is called twice
|
||||||
|
Then the two `manifest_hash` values differ — D-C10-1 idempotence treats re-planned route as a new build
|
||||||
|
|
||||||
|
**AC-16: `manifest_hash` stable when only `flight_id` differs but `takeoff_origin` is the same**
|
||||||
|
Given two `ManifestBuildInput`s identical except `flight_id`
|
||||||
|
When `build_manifest` is called twice
|
||||||
|
Then the two `manifest_hash` values **differ** — `flight_id` is provenance and is part of the build identity (operator may re-plan with the same takeoff position but a different mission; the cache identity must track that)
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
@@ -199,6 +227,10 @@ Then it equals `N + 3` (engines + index + calibration + tiles_coverage); does NO
|
|||||||
| AC-10 | Kill mid-write | No half-Manifest |
|
| AC-10 | Kill mid-write | No half-Manifest |
|
||||||
| AC-11 | Verify Manifest's own sidecar | Hashes match |
|
| AC-11 | Verify Manifest's own sidecar | Hashes match |
|
||||||
| AC-12 | Inspect total_artifacts_listed | Counts engines+index+calibration+tiles_coverage |
|
| AC-12 | Inspect total_artifacts_listed | Counts engines+index+calibration+tiles_coverage |
|
||||||
|
| AC-13 | Build with takeoff_origin set | `flight.takeoff_origin` present in JSON; lat/lon/alt match |
|
||||||
|
| AC-14 | Build with takeoff_origin=None | `flight.takeoff_origin` key absent from JSON |
|
||||||
|
| AC-15 | Two builds, takeoff_origin differs | manifest_hash differs |
|
||||||
|
| AC-16 | Two builds, only flight_id differs | manifest_hash differs |
|
||||||
| NFR-perf | 100k-tile bench | ≤ 5 s wall clock |
|
| NFR-perf | 100k-tile bench | ≤ 5 s wall clock |
|
||||||
| NFR-reliability-fail-closed | Operator mode + unknown fp | Fail-closed; nothing written |
|
| NFR-reliability-fail-closed | Operator mode + unknown fp | Fail-closed; nothing written |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Task**: AZ-324_c10_manifest_verifier
|
**Task**: AZ-324_c10_manifest_verifier
|
||||||
**Name**: C10 ManifestVerifier
|
**Name**: C10 ManifestVerifier
|
||||||
**Description**: Implement `ManifestVerifier` (per the contract `_docs/02_document/contracts/c10_provisioning/manifest_verifier.md`), the read-only validator that AC-NEW-1 places between F2 takeoff and any engine deserialization. Loads `Manifest.json`, verifies its sidecar SHA-256 matches the Manifest bytes, parses the Ed25519 detached signature at `Manifest.json.sig`, verifies it against the caller-supplied `trusted_public_keys` tuple, parses the Manifest schema (rejecting absolute paths and schema violations), and walks every per-artifact entry re-hashing it via AZ-280's sidecar pattern. Returns a `VerificationResult` with `outcome ∈ {PASS, FAIL}`, the union of all `VerifyFailReason` values that fired, the populated `per_artifact_checks` list, and `elapsed_ms`. Fail-closed: any deviation in signature, schema, key trust, or hashes yields `FAIL` with detailed reasons. Never raises on a verify failure — only on environment errors (Manifest.json missing → `MANIFEST_NOT_FOUND` is still `FAIL`, not raise).
|
**Description**: Implement `ManifestVerifier` (per the contract `_docs/02_document/contracts/c10_provisioning/manifest_verifier.md` v1.1.0), the read-only validator that AC-NEW-1 places between F2 takeoff and any engine deserialization. Loads `Manifest.json`, verifies its sidecar SHA-256 matches the Manifest bytes, parses the Ed25519 detached signature at `Manifest.json.sig`, verifies it against the caller-supplied `trusted_public_keys` tuple, parses the Manifest schema (rejecting absolute paths and schema violations), validates the optional `flight.takeoff_origin` block (well-formed `LatLonAlt` + inside `build.bbox` per ADR-010 + AZ-490), and walks every per-artifact entry re-hashing it via AZ-280's sidecar pattern. Returns a `VerificationResult` with `outcome ∈ {PASS, FAIL}`, the union of all `VerifyFailReason` values that fired, the populated `per_artifact_checks` list, the pass-through `takeoff_origin` + `flight_id` (or `None` when absent from the Manifest body), and `elapsed_ms`. Fail-closed: any deviation in signature, schema, key trust, hashes, or origin validity yields `FAIL` with detailed reasons. Never raises on a verify failure — only on environment errors (Manifest.json missing → `MANIFEST_NOT_FOUND` is still `FAIL`, not raise).
|
||||||
**Complexity**: 3 points
|
**Complexity**: 3 points
|
||||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema
|
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema
|
||||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||||
@@ -56,9 +56,15 @@ This task delivers the verifier + its frozen contract. It does NOT compile engin
|
|||||||
- If `trusted_public_keys` is empty: append `UNTRUSTED_PUBLIC_KEY`; return `FAIL`.
|
- If `trusted_public_keys` is empty: append `UNTRUSTED_PUBLIC_KEY`; return `FAIL`.
|
||||||
5. **Step C — Schema parse**:
|
5. **Step C — Schema parse**:
|
||||||
- `orjson.loads(manifest_bytes)` → dict.
|
- `orjson.loads(manifest_bytes)` → dict.
|
||||||
- Validate required keys: `schema_version`, `build` (with sub-keys `bbox`, `zoom_levels`, `sector_class`, `built_at`, `manifest_hash`), `artifacts` (with `engines`, `descriptor_index`, `calibration`, `tiles_coverage`), `signing_public_key_fingerprint`.
|
- Validate required keys: `schema_version`, `build` (with sub-keys `bbox`, `zoom_levels`, `sector_class`, `built_at`, `manifest_hash`), `artifacts` (with `engines`, `descriptor_index`, `calibration`, `tiles_coverage`), `signing_public_key_fingerprint`. `flight` block is OPTIONAL (added in schema v1.1, ADR-010).
|
||||||
- Validate types: `engines` is list of `{path: str, sha256: str}`; `descriptor_index`, `calibration` are `{path: str, sha256: str}`; `tiles_coverage` is `{sha256: str, tile_count: int}`.
|
- Validate types: `engines` is list of `{path: str, sha256: str}`; `descriptor_index`, `calibration` are `{path: str, sha256: str}`; `tiles_coverage` is `{sha256: str, tile_count: int}`.
|
||||||
- Validate path-relative-only: every `path` value must be relative (no leading `/`, no `..` segments). Append `SCHEMA_VIOLATION` per offending field; if any, return `FAIL`.
|
- Validate path-relative-only: every `path` value must be relative (no leading `/`, no `..` segments). Append `SCHEMA_VIOLATION` per offending field; if any, return `FAIL`.
|
||||||
|
- **Flight block (ADR-010 / AZ-490)**:
|
||||||
|
- If `flight` key absent → `takeoff_origin = None`, `flight_id = None`; continue.
|
||||||
|
- If `flight` present → parse `flight_id` (`UUID` or `None`) and `takeoff_origin` (optional block).
|
||||||
|
- If `flight.takeoff_origin` present → validate `lat_deg ∈ [-90, 90]`, `lon_deg ∈ [-180, 180]`, `alt_m` finite (no NaN/Inf). Append `TAKEOFF_ORIGIN_INVALID` to `fail_reasons` and the offending field name to `fail_details` if any check fails.
|
||||||
|
- If `flight.takeoff_origin` is well-formed → check it falls inside `build.bbox` (`bbox.lat_min ≤ lat ≤ bbox.lat_max`, `bbox.lon_min ≤ lon ≤ bbox.lon_max`). Append `TAKEOFF_ORIGIN_OUT_OF_BBOX` if not.
|
||||||
|
- The `takeoff_origin` is populated on `VerificationResult` whenever the block parsed (even on FAIL), per MV-INV-9, so operators see what was attempted.
|
||||||
6. **Step D — Per-artifact hash walk** (only reached if Steps A–C all passed):
|
6. **Step D — Per-artifact hash walk** (only reached if Steps A–C all passed):
|
||||||
- For each engine, descriptor_index, calibration entry:
|
- For each engine, descriptor_index, calibration entry:
|
||||||
- Compute `actual_path = manifest_path.parent / entry.path`.
|
- Compute `actual_path = manifest_path.parent / entry.path`.
|
||||||
@@ -166,6 +172,26 @@ Given `trusted_public_keys = ()`
|
|||||||
When verify runs
|
When verify runs
|
||||||
Then `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` regardless of Manifest validity; per-artifact walk does NOT happen
|
Then `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` regardless of Manifest validity; per-artifact walk does NOT happen
|
||||||
|
|
||||||
|
**AC-14: Manifest with no `flight` block parses cleanly (back-compat)**
|
||||||
|
Given a v1.0 Manifest (no `flight` block) that is otherwise valid + signed
|
||||||
|
When verify runs
|
||||||
|
Then `outcome=PASS`; `VerificationResult.takeoff_origin is None`; `VerificationResult.flight_id is None`
|
||||||
|
|
||||||
|
**AC-15: Well-formed in-bbox `takeoff_origin` passes through**
|
||||||
|
Given a v1.1 Manifest with `flight.takeoff_origin = (50.0, 36.2, 200.0)` inside the recorded bbox
|
||||||
|
When verify runs
|
||||||
|
Then `outcome=PASS`; `VerificationResult.takeoff_origin == LatLonAlt(50.0, 36.2, 200.0)`
|
||||||
|
|
||||||
|
**AC-16: Malformed `takeoff_origin` (lat=200) fails closed**
|
||||||
|
Given a Manifest with `flight.takeoff_origin.lat_deg = 200`
|
||||||
|
When verify runs
|
||||||
|
Then `outcome=FAIL`; `fail_reasons` contains `TAKEOFF_ORIGIN_INVALID`; `fail_details` names `lat_deg`; the `takeoff_origin` field on `VerificationResult` is still populated for diagnostics
|
||||||
|
|
||||||
|
**AC-17: Out-of-bbox `takeoff_origin` fails closed**
|
||||||
|
Given a Manifest whose `flight.takeoff_origin = (10.0, 10.0, 0)` while `build.bbox` covers `(49.5..50.5, 35.5..36.5)`
|
||||||
|
When verify runs
|
||||||
|
Then `outcome=FAIL`; `fail_reasons` contains `TAKEOFF_ORIGIN_OUT_OF_BBOX`
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
@@ -197,6 +223,10 @@ Then `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` regardless of Manifest validity; per
|
|||||||
| AC-9 | Operator mode + drifted tile | TILES_COVERAGE_MISMATCH |
|
| AC-9 | Operator mode + drifted tile | TILES_COVERAGE_MISMATCH |
|
||||||
| AC-10 | Airborne mode | tiles_coverage matched=True |
|
| AC-10 | Airborne mode | tiles_coverage matched=True |
|
||||||
| AC-11 | Conformance check | True |
|
| AC-11 | Conformance check | True |
|
||||||
|
| AC-14 | v1.0 Manifest (no flight block) | PASS; takeoff_origin=None; flight_id=None |
|
||||||
|
| AC-15 | v1.1 Manifest, valid in-bbox origin | PASS; takeoff_origin populated |
|
||||||
|
| AC-16 | Malformed origin (lat=200) | FAIL; TAKEOFF_ORIGIN_INVALID; field name in details |
|
||||||
|
| AC-17 | Out-of-bbox origin | FAIL; TAKEOFF_ORIGIN_OUT_OF_BBOX |
|
||||||
| AC-12 | Inspect elapsed_ms | All non-negative; ordered as expected |
|
| AC-12 | Inspect elapsed_ms | All non-negative; ordered as expected |
|
||||||
| AC-13 | Empty trusted keys | FAIL; UNTRUSTED |
|
| AC-13 | Empty trusted keys | FAIL; UNTRUSTED |
|
||||||
| NFR-perf-airborne | 5 artifact bench, no tile re-walk | p99 ≤ 100 ms |
|
| NFR-perf-airborne | 5 artifact bench, no tile re-walk | p99 ≤ 100 ms |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Task**: AZ-325_c10_cache_provisioner
|
**Task**: AZ-325_c10_cache_provisioner
|
||||||
**Name**: C10 CacheProvisioner
|
**Name**: C10 CacheProvisioner
|
||||||
**Description**: Implement `CacheProvisioner` (per the contract `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md`), the public top-level orchestrator that composes AZ-321 (EngineCompiler), AZ-322 (DescriptorBatcher), and AZ-323 (ManifestBuilder) into a single idempotent F1 build pipeline. Acquires a `cache_root/.c10.lock` filesystem lockfile to enforce CP-INV-4. Computes the build-identity hash from the same canonical inputs AZ-323 hashes (model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels) and compares to the existing `Manifest.json`'s `manifest_hash`; on match → `outcome=IDEMPOTENT_NO_OP`. On mismatch (or no prior Manifest) → run engine compile → descriptor population → Manifest build, then walk `cache_root` to confirm every file is listed in the new Manifest's `artifacts` section, raising `ManifestCoverageError` on orphans (with rollback to prior-good Manifest). Empty corpus → `BuildReport(outcome=FAILURE, failure_reason="run C11 TileDownloader first")` per description.md § 5.
|
**Description**: Implement `CacheProvisioner` (per the contract `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md` v1.1.0), the public top-level orchestrator that composes AZ-321 (EngineCompiler), AZ-322 (DescriptorBatcher), and AZ-323 (ManifestBuilder) into a single idempotent F1 build pipeline. Acquires a `cache_root/.c10.lock` filesystem lockfile to enforce CP-INV-4. Computes the build-identity hash from the same canonical inputs AZ-323 hashes (model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels **+ takeoff_origin + flight_id**) and compares to the existing `Manifest.json`'s `manifest_hash`; on match → `outcome=IDEMPOTENT_NO_OP`. On mismatch (or no prior Manifest) → run engine compile → descriptor population → Manifest build (passing `request.takeoff_origin` and `request.flight_id` to AZ-323), then walk `cache_root` to confirm every file is listed in the new Manifest's `artifacts` section, raising `ManifestCoverageError` on orphans (with rollback to prior-good Manifest). Empty corpus → `BuildReport(outcome=FAILURE, failure_reason="run C11 TileDownloader first")` per description.md § 5. **A request whose `takeoff_origin` differs from the prior Manifest's by ≥ 1 mm is treated as a new build identity (CP-INV-8) — this is the contract that lets `ManifestVerifier` reject a re-planned route at boot.**
|
||||||
**Complexity**: 3 points
|
**Complexity**: 3 points
|
||||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-303_c6_storage_interfaces, AZ-321_c10_engine_compiler, AZ-322_c10_descriptor_batcher, AZ-323_c10_manifest_builder
|
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-303_c6_storage_interfaces, AZ-321_c10_engine_compiler, AZ-322_c10_descriptor_batcher, AZ-323_c10_manifest_builder
|
||||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||||
@@ -40,13 +40,13 @@ This task delivers the orchestrator + its frozen contract. It does NOT compile e
|
|||||||
2. **Tile gathering**: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)`.
|
2. **Tile gathering**: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)`.
|
||||||
- If empty → return `BuildReport(outcome=FAILURE, failure_reason="no tiles in C6 for the requested scope; run C11 TileDownloader first", engines_built=0, ...)`. ERROR log; release lock.
|
- If empty → return `BuildReport(outcome=FAILURE, failure_reason="no tiles in C6 for the requested scope; run C11 TileDownloader first", engines_built=0, ...)`. ERROR log; release lock.
|
||||||
3. **Build-identity hash for idempotence check**:
|
3. **Build-identity hash for idempotence check**:
|
||||||
- Compute `request_hash = sha256(canonical_json(model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels))`. The `model_ids` come from the configured backbone list; `calibration_sha256` from streaming the calibration_path; `tiles_coverage_sha256` from sorting the tile rows by `(zoom, lat, lon, source)` and hashing per AZ-323's algorithm.
|
- Compute `request_hash = sha256(canonical_json(model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels + takeoff_origin_tuple_or_none + flight_id_or_none))`. The `model_ids` come from the configured backbone list; `calibration_sha256` from streaming the calibration_path; `tiles_coverage_sha256` from sorting the tile rows by `(zoom, lat, lon, source)` and hashing per AZ-323's algorithm. `takeoff_origin_tuple_or_none` is `(lat_deg, lon_deg, alt_m)` rounded to 9 decimal places when `request.takeoff_origin is not None`, otherwise the JSON `null` sentinel (CP-INV-8). The hashing formula MUST match AZ-323 exactly so AZ-325's idempotence decision agrees with AZ-323's emitted `build.manifest_hash`.
|
||||||
- Read existing `Manifest.json` if present; parse only the `build.manifest_hash` field (don't run full verification — that's AZ-324's job). If `existing.manifest_hash == request_hash` → return `BuildReport(outcome=IDEMPOTENT_NO_OP, manifest_hash=existing.manifest_hash, manifest_path=existing_path, engines_built=0, engines_reused=0, descriptors_generated=0, elapsed_s, failure_reason=None)`. INFO log; release lock.
|
- Read existing `Manifest.json` if present; parse only the `build.manifest_hash` field (don't run full verification — that's AZ-324's job). If `existing.manifest_hash == request_hash` → return `BuildReport(outcome=IDEMPOTENT_NO_OP, manifest_hash=existing.manifest_hash, manifest_path=existing_path, engines_built=0, engines_reused=0, descriptors_generated=0, elapsed_s, failure_reason=None)`. INFO log; release lock.
|
||||||
4. **Active build path**:
|
4. **Active build path**:
|
||||||
- Snapshot prior-good Manifest (rename to `Manifest.json.prev` if present) for rollback.
|
- Snapshot prior-good Manifest (rename to `Manifest.json.prev` if present) for rollback.
|
||||||
- Compose engine compile request from configured backbones; call `engine_compiler.compile_engines_for_corpus(...)` → `engine_entries`.
|
- Compose engine compile request from configured backbones; call `engine_compiler.compile_engines_for_corpus(...)` → `engine_entries`.
|
||||||
- Compose descriptor populate request (filter, callback hooked to logger); call `descriptor_batcher.populate_descriptors(...)` → `DescriptorBatchReport`. If `outcome=failure` → restore prior Manifest, release lock, return `BuildReport(outcome=FAILURE, failure_reason=batch.failure_reason, ...)`.
|
- Compose descriptor populate request (filter, callback hooked to logger); call `descriptor_batcher.populate_descriptors(...)` → `DescriptorBatchReport`. If `outcome=failure` → restore prior Manifest, release lock, return `BuildReport(outcome=FAILURE, failure_reason=batch.failure_reason, ...)`.
|
||||||
- Compose Manifest build input from engine entries + descriptor index path + calibration + key_path; call `manifest_builder.build_manifest(...)` → `ManifestArtifact`.
|
- Compose Manifest build input from engine entries + descriptor index path + calibration + key_path **+ `request.takeoff_origin` + `request.flight_id`** (ADR-010); call `manifest_builder.build_manifest(...)` → `ManifestArtifact`. Both fields default to `None` when the caller did not supply them (e.g., legacy C12 invocation without `--flight-id`).
|
||||||
5. **Coverage check** (CP-INV-3 / D-C10-3):
|
5. **Coverage check** (CP-INV-3 / D-C10-3):
|
||||||
- Walk `cache_root` recursively (`pathlib.Path.rglob`); collect every regular file path EXCLUDING `Manifest.json`, `Manifest.json.sha256`, `Manifest.json.sig`, `Manifest.json.prev`, `.c10.lock`, and any `.sha256` sidecar (sidecars are implicit per the AZ-280 pattern, paired with their primary).
|
- Walk `cache_root` recursively (`pathlib.Path.rglob`); collect every regular file path EXCLUDING `Manifest.json`, `Manifest.json.sha256`, `Manifest.json.sig`, `Manifest.json.prev`, `.c10.lock`, and any `.sha256` sidecar (sidecars are implicit per the AZ-280 pattern, paired with their primary).
|
||||||
- Build expected set: every `path` in `manifest.artifacts.engines + descriptor_index + calibration` (resolved relative to `cache_root`).
|
- Build expected set: every `path` in `manifest.artifacts.engines + descriptor_index + calibration` (resolved relative to `cache_root`).
|
||||||
@@ -152,6 +152,21 @@ Given a populated cache and identical request
|
|||||||
When `build_cache_artifacts` runs
|
When `build_cache_artifacts` runs
|
||||||
Then wall-clock ≤ 1 min (CP-TC-13 / NFR C10-PT-01); the bound work is the build-identity hash computation, which is dominated by `tiles_coverage_sha256` over 1000 tiles (~5 ms hashing)
|
Then wall-clock ≤ 1 min (CP-TC-13 / NFR C10-PT-01); the bound work is the build-identity hash computation, which is dominated by `tiles_coverage_sha256` over 1000 tiles (~5 ms hashing)
|
||||||
|
|
||||||
|
**AC-14: `takeoff_origin` mismatch triggers full rebuild (ADR-010 / CP-INV-8)**
|
||||||
|
Given a prior Manifest built with `takeoff_origin = A`
|
||||||
|
When `build_cache_artifacts` is called with the SAME bbox / zooms / sector / calibration / tiles, but `takeoff_origin = B (B ≠ A by ≥ 1 mm)`
|
||||||
|
Then `outcome=SUCCESS` (NOT `IDEMPOTENT_NO_OP`); the new Manifest replaces the old; the new `manifest_hash` differs from the prior; the new Manifest's `flight.takeoff_origin` matches B
|
||||||
|
|
||||||
|
**AC-15: `takeoff_origin = None` propagates through with no flight block in Manifest (back-compat)**
|
||||||
|
Given a `BuildRequest` with `takeoff_origin = None` and `flight_id = None`
|
||||||
|
When `build_cache_artifacts` runs
|
||||||
|
Then `outcome=SUCCESS`; the produced Manifest has no `flight.takeoff_origin` key (AZ-323's AC-14); idempotence still works for subsequent identical-without-origin invocations
|
||||||
|
|
||||||
|
**AC-16: `flight_id` participation in idempotence**
|
||||||
|
Given a prior Manifest built with `flight_id = X, takeoff_origin = A`
|
||||||
|
When `build_cache_artifacts` runs with `flight_id = Y, takeoff_origin = A` (only `flight_id` differs)
|
||||||
|
Then `outcome=SUCCESS` (NOT `IDEMPOTENT_NO_OP`); `flight_id` is part of the build identity per CP-INV-8
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
@@ -177,6 +192,9 @@ Then wall-clock ≤ 1 min (CP-TC-13 / NFR C10-PT-01); the bound work is the buil
|
|||||||
| AC-2 | Warm re-run with identical request | IDEMPOTENT_NO_OP; zero phase calls |
|
| AC-2 | Warm re-run with identical request | IDEMPOTENT_NO_OP; zero phase calls |
|
||||||
| AC-3 | Different bbox after prior build | SUCCESS; atomic replace; old Manifest gone |
|
| AC-3 | Different bbox after prior build | SUCCESS; atomic replace; old Manifest gone |
|
||||||
| AC-4 | Empty C6 query | FAILURE; hint string; lock released |
|
| AC-4 | Empty C6 query | FAILURE; hint string; lock released |
|
||||||
|
| AC-14 | Warm re-run with different takeoff_origin | SUCCESS; new manifest_hash; phases called |
|
||||||
|
| AC-15 | Build with takeoff_origin=None | SUCCESS; Manifest has no flight.takeoff_origin |
|
||||||
|
| AC-16 | Warm re-run with different flight_id only | SUCCESS; new manifest_hash |
|
||||||
| AC-5 | Pre-acquire lock externally; run | BuildLockHeldError |
|
| AC-5 | Pre-acquire lock externally; run | BuildLockHeldError |
|
||||||
| AC-6 | Inject orphan file before coverage walk | ManifestCoverageError; prior Manifest restored |
|
| AC-6 | Inject orphan file before coverage walk | ManifestCoverageError; prior Manifest restored |
|
||||||
| AC-7 | Same as AC-6 with `coverage_strict=False` | SUCCESS; WARN log |
|
| AC-7 | Same as AC-6 with `coverage_strict=False` | SUCCESS; WARN log |
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
**Task**: AZ-326_c12_cli_app
|
**Task**: AZ-326_c12_cli_app
|
||||||
**Name**: C12 CLI App
|
**Name**: C12 CLI App
|
||||||
**Description**: Implement the operator-tooling CLI shell that operators run on the workstation. Wires Typer (per the Click/Typer project pin) into `operator_tool/__main__.py`, registers six subcommands (`download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`), wires the E-CC-LOG (AZ-266) logger to a workstation-side structured-JSON log file (`~/.azaion/onboard/c12-tooling.log`), and ships the two trivial operator-side helpers from description.md § 2 — `set_sector_classification(area, sector_class)` (persists per-area classification to a local JSON file under the operator workstation's home directory) and `apply_freshness_threshold(sector_class) -> int (months)` (a pure-data lookup that maps the sector classification enum to the AC-NEW-6 months freshness budget). Each subcommand is a thin shell that resolves its service collaborator (`build_cache`, `companion_bringup`, `post_landing_upload`, `operator_reloc_service` — all owned by sibling tasks AZ-NNN T2..T5) from the composition root and delegates to it; on success returns 0; on a known error type maps to a documented non-zero exit code with a one-line operator-friendly message + remediation hint pulled from the underlying error's `remediation` attribute. The CLI app does NOT own any workflow logic itself — only command registration, argument parsing, logger wiring, exit-code mapping, and the two simple operator helpers.
|
**Description**: Implement the operator-tooling CLI shell that operators run on the workstation. Wires Typer (per the Click/Typer project pin) into `operator_tool/__main__.py`, registers six subcommands (`download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`), wires the E-CC-LOG (AZ-266) logger to a workstation-side structured-JSON log file (`~/.azaion/onboard/c12-tooling.log`), and ships the two trivial operator-side helpers from description.md § 2 — `set_sector_classification(area, sector_class)` (persists per-area classification to a local JSON file under the operator workstation's home directory) and `apply_freshness_threshold(sector_class) -> int (months)` (a pure-data lookup that maps the sector classification enum to the AC-NEW-6 months freshness budget). Each subcommand is a thin shell that resolves its service collaborator (`flights_api_client`, `build_cache`, `companion_bringup`, `post_landing_upload`, `operator_reloc_service` — all owned by sibling tasks AZ-489 / AZ-NNN T2..T5) from the composition root and delegates to it; on success returns 0; on a known error type maps to a documented non-zero exit code with a one-line operator-friendly message + remediation hint pulled from the underlying error's `remediation` attribute. The CLI app does NOT own any workflow logic itself — only command registration, argument parsing, logger wiring, exit-code mapping, and the two simple operator helpers. **ADR-010 amendment**: the `build-cache` subcommand accepts a mutually-exclusive pair `--flight-id <Guid> | --flight-file <Path>` and forwards the resolved `FlightDto` (via AZ-489 `FlightsApiClient`) to the orchestrator (AZ-328), which derives the bbox + takeoff origin from it. The legacy `--bbox` flag is dropped because the bbox is now derived; passing it is an error.
|
||||||
**Complexity**: 3 points
|
**Complexity**: 3 points
|
||||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
|
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-489_c12_flights_api_client (for the `FlightsApiClient` service collaborator + DTO definitions surfaced via `--flight-id` / `--flight-file`)
|
||||||
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
||||||
**Tracker**: AZ-326
|
**Tracker**: AZ-326
|
||||||
**Epic**: AZ-253 (E-C12)
|
**Epic**: AZ-253 (E-C12)
|
||||||
@@ -42,12 +42,12 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
|
|||||||
- `src/operator_tool/freshness_table.py` — `freshness_threshold_months(sector_class: SectorClassification) -> int`:
|
- `src/operator_tool/freshness_table.py` — `freshness_threshold_months(sector_class: SectorClassification) -> int`:
|
||||||
- Pure data: `active_conflict → 1 month`; `stable_rear → 12 months`. Documented inline as the AC-NEW-6 freshness budget per description.md § 1 + Plan-phase intent.
|
- Pure data: `active_conflict → 1 month`; `stable_rear → 12 months`. Documented inline as the AC-NEW-6 freshness budget per description.md § 1 + Plan-phase intent.
|
||||||
- Module-level constant: `FRESHNESS_TABLE: dict[SectorClassification, int]`.
|
- Module-level constant: `FRESHNESS_TABLE: dict[SectorClassification, int]`.
|
||||||
- `src/operator_tool/exit_codes.py` — module-level constants: `EXIT_OK = 0`, `EXIT_GENERIC_ERROR = 1`, `EXIT_USAGE = 2`, `EXIT_COMPANION_UNREACHABLE = 10`, `EXIT_CONTENT_HASH_MISMATCH = 11`, `EXIT_DOWNLOAD_FAILURE = 20`, `EXIT_BUILD_FAILURE = 21`, `EXIT_FLIGHT_STATE_NOT_CONFIRMED = 30`, `EXIT_UPLOAD_FAILURE = 31`, `EXIT_GCS_LINK_ERROR = 40`, `EXIT_LOCK_HELD = 50`. Sibling tasks may extend with documented additions.
|
- `src/operator_tool/exit_codes.py` — module-level constants: `EXIT_OK = 0`, `EXIT_GENERIC_ERROR = 1`, `EXIT_USAGE = 2`, `EXIT_COMPANION_UNREACHABLE = 10`, `EXIT_CONTENT_HASH_MISMATCH = 11`, `EXIT_DOWNLOAD_FAILURE = 20`, `EXIT_BUILD_FAILURE = 21`, `EXIT_FLIGHT_STATE_NOT_CONFIRMED = 30`, `EXIT_UPLOAD_FAILURE = 31`, `EXIT_GCS_LINK_ERROR = 40`, `EXIT_LOCK_HELD = 50`, `EXIT_FLIGHTS_API_UNREACHABLE = 60`, `EXIT_FLIGHTS_API_AUTH = 61`, `EXIT_FLIGHT_NOT_FOUND = 62`, `EXIT_FLIGHT_SCHEMA = 63`, `EXIT_EMPTY_WAYPOINTS = 64`. Sibling tasks may extend with documented additions.
|
||||||
- A composition root entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
|
- A composition root entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
|
||||||
- `build_operator_tool(config: Config) -> OperatorToolServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-tool service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
|
- `build_operator_tool(config: Config) -> OperatorToolServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-tool service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
|
||||||
- Subcommand surface (each subcommand body lives in `cli.py`; service implementations live in sibling task files):
|
- Subcommand surface (each subcommand body lives in `cli.py`; service implementations live in sibling task files):
|
||||||
- `download` — delegates to `tile_downloader.fetch(...)` (AZ-316). Maps `SatelliteProviderError → EXIT_DOWNLOAD_FAILURE`.
|
- `download` — delegates to `tile_downloader.fetch(...)` (AZ-316). Maps `SatelliteProviderError → EXIT_DOWNLOAD_FAILURE`.
|
||||||
- `build-cache` — delegates to `build_cache_orchestrator.build_cache(...)` (sibling T3). Maps `CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE` (per `failure_phase`); `BuildLockHeldError → EXIT_LOCK_HELD`.
|
- `build-cache` — accepts a mutually-exclusive pair `--flight-id <Guid> | --flight-file <Path>` (Typer-enforced via a callback that rejects both-set / neither-set with `EXIT_USAGE`), plus `--sector-class`, `--calibration-path`. Delegates to `build_cache_orchestrator.build_cache(...)` (sibling AZ-328) passing the resolved `FlightDto` (the orchestrator computes bbox + takeoff origin from it via AZ-489 helpers). Maps `CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE` (per `failure_phase`); `BuildLockHeldError → EXIT_LOCK_HELD`; `FlightsApiUnreachableError → EXIT_FLIGHTS_API_UNREACHABLE`; `FlightsApiAuthError → EXIT_FLIGHTS_API_AUTH`; `FlightNotFoundError → EXIT_FLIGHT_NOT_FOUND`; `FlightsApiSchemaError | FlightFileNotFoundError | WaypointSchemaError → EXIT_FLIGHT_SCHEMA`; `EmptyWaypointsError → EXIT_EMPTY_WAYPOINTS`.
|
||||||
- `upload-pending` — delegates to `post_landing_upload.trigger_post_landing_upload(...)` (sibling T4). Maps `FlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED`; `UploadGateBlockedError → EXIT_UPLOAD_FAILURE`.
|
- `upload-pending` — delegates to `post_landing_upload.trigger_post_landing_upload(...)` (sibling T4). Maps `FlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED`; `UploadGateBlockedError → EXIT_UPLOAD_FAILURE`.
|
||||||
- `reloc-confirm` — delegates to `operator_reloc_service.request_reloc(...)` (sibling T5). Maps `GcsLinkError → EXIT_GCS_LINK_ERROR`.
|
- `reloc-confirm` — delegates to `operator_reloc_service.request_reloc(...)` (sibling T5). Maps `GcsLinkError → EXIT_GCS_LINK_ERROR`.
|
||||||
- `verify-ready` — delegates to `companion_bringup.verify_companion_ready(...)` (sibling T2). Maps `CompanionUnreachableError → EXIT_COMPANION_UNREACHABLE`; `ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH`.
|
- `verify-ready` — delegates to `companion_bringup.verify_companion_ready(...)` (sibling T2). Maps `CompanionUnreachableError → EXIT_COMPANION_UNREACHABLE`; `ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH`.
|
||||||
@@ -131,6 +131,39 @@ Given `set-sector --area Derkachi --class active_conflict` was just run
|
|||||||
When the same command is run again
|
When the same command is run again
|
||||||
Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the log, not in the data file); the operator sees the same exit code 0 and the same INFO log line
|
Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the log, not in the data file); the operator sees the same exit code 0 and the same INFO log line
|
||||||
|
|
||||||
|
**AC-11: `build-cache --flight-id` happy path delegates to orchestrator with `FlightDto` (ADR-010)**
|
||||||
|
Given a fake `FlightsApiClient.fetch_flight` returns a 3-waypoint `FlightDto`
|
||||||
|
When `operator-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json` runs
|
||||||
|
Then `build_cache_orchestrator.build_cache(...)` is called once with the resolved `FlightDto` (or its `(flight_id, bbox, takeoff_origin)` projection per AZ-328 signature); ZERO calls to `--bbox` legacy parsing
|
||||||
|
|
||||||
|
**AC-12: `build-cache --flight-file` happy path uses offline loader**
|
||||||
|
Given a local JSON file in the documented schema is on disk
|
||||||
|
When `operator-tool build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json` runs
|
||||||
|
Then `FlightsApiClient.load_flight_file(/tmp/flight.json)` is called once; `fetch_flight` is NOT called; the orchestrator receives the same DTO shape
|
||||||
|
|
||||||
|
**AC-13: `build-cache` with both `--flight-id` and `--flight-file` errors out**
|
||||||
|
When `operator-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ...` runs
|
||||||
|
Then exit code is `EXIT_USAGE = 2`; stderr names the conflict; ZERO calls to either client method
|
||||||
|
|
||||||
|
**AC-14: `build-cache` with neither `--flight-id` nor `--flight-file` errors out**
|
||||||
|
When `operator-tool build-cache --sector-class stable_rear --calibration-path /tmp/cal.json` runs (no flight source)
|
||||||
|
Then exit code is `EXIT_USAGE = 2`; stderr lists which flag must be supplied
|
||||||
|
|
||||||
|
**AC-15: `FlightNotFoundError` maps to `EXIT_FLIGHT_NOT_FOUND`**
|
||||||
|
Given `fetch_flight` raises `FlightNotFoundError`
|
||||||
|
When `build-cache --flight-id <unknown>` runs
|
||||||
|
Then exit code is `62`; ERROR log carries the offending flight_id; ZERO calls to C11/C10
|
||||||
|
|
||||||
|
**AC-16: `FlightsApiAuthError` maps to `EXIT_FLIGHTS_API_AUTH`** (and never logs the auth token)
|
||||||
|
Given `fetch_flight` raises `FlightsApiAuthError`
|
||||||
|
When `build-cache --flight-id <id>` runs
|
||||||
|
Then exit code is `61`; the structured log entry does NOT contain the `auth_token` value
|
||||||
|
|
||||||
|
**AC-17: `EmptyWaypointsError` maps to `EXIT_EMPTY_WAYPOINTS`**
|
||||||
|
Given the fetched `FlightDto` has zero waypoints
|
||||||
|
When `build-cache --flight-id <id>` runs (and the orchestrator calls `bbox_from_waypoints` → raises)
|
||||||
|
Then exit code is `64`; the stderr message instructs the operator to re-plan in the Mission Planner UI
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
**Performance**
|
**Performance**
|
||||||
@@ -158,6 +191,13 @@ Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the
|
|||||||
| AC-8 | `subprocess.run(["operator-tool", "--help"])` after `pip install -e .` | Exit 0, help text printed |
|
| AC-8 | `subprocess.run(["operator-tool", "--help"])` after `pip install -e .` | Exit 0, help text printed |
|
||||||
| AC-9 | Per-subcommand `--help` text | Includes documented AC IDs |
|
| AC-9 | Per-subcommand `--help` text | Includes documented AC IDs |
|
||||||
| AC-10 | Repeated `set-sector` for same area/class | On-disk JSON byte-identical |
|
| AC-10 | Repeated `set-sector` for same area/class | On-disk JSON byte-identical |
|
||||||
|
| AC-11 | `build-cache --flight-id` happy path | Orchestrator called once with resolved DTO |
|
||||||
|
| AC-12 | `build-cache --flight-file` happy path | `load_flight_file` called; `fetch_flight` NOT called |
|
||||||
|
| AC-13 | Both `--flight-id` and `--flight-file` | Exit 2; conflict message |
|
||||||
|
| AC-14 | Neither flight source supplied | Exit 2; usage hint |
|
||||||
|
| AC-15 | `FlightNotFoundError` | Exit 62; flight_id in log |
|
||||||
|
| AC-16 | `FlightsApiAuthError` | Exit 61; auth_token NOT in log |
|
||||||
|
| AC-17 | `EmptyWaypointsError` | Exit 64; Mission Planner UI hint |
|
||||||
| NFR-perf-cold-start | Microbench `operator-tool --help` × 10 | p99 ≤ 500 ms |
|
| NFR-perf-cold-start | Microbench `operator-tool --help` × 10 | p99 ≤ 500 ms |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
**Task**: AZ-328_c12_build_cache_orchestrator
|
**Task**: AZ-328_c12_build_cache_orchestrator
|
||||||
**Name**: C12 Build-Cache Orchestrator
|
**Name**: C12 Build-Cache Orchestrator
|
||||||
**Description**: Implement `BuildCacheOrchestrator`, the public top-level F1 (pre-flight cache build) workflow. `build_cache(request: BuildCacheRequest) -> CacheBuildReport` does the following sequenced work, with strict ordering: (1) acquire a filesystem lockfile at `<cache_staging_root>/.c12.lock` per description.md § 7 (prevents concurrent F1 runs from stomping each other); (2) call `tile_downloader.fetch(...)` (AZ-316) on the operator workstation with `area`, `sector_class`, `freshness_threshold_months`, `satellite_provider_url`, `api_key`; (3) on download `failure` outcome → wrap as `CacheBuildError(failure_phase=download, ...)` and return `CacheBuildReport(outcome=failure, failure_phase=download, download_report=..., build_report=None)` WITHOUT invoking C10; (4) on download `success` → call `companion_bringup.verify_companion_ready(...)` (AZ-327) — if `not_ready` → wrap and return `CacheBuildReport(outcome=failure, failure_phase=download, ...)` because the artifacts the C11 step pushed to the companion did not survive the verification (the boundary case here is that the operator workstation may have ingested tiles into local C6 but the companion's pre-existing artifacts are stale); (5) SSH-invoke `C10.CacheProvisioner.build_cache_artifacts` (AZ-325) on the companion via the `RemoteCacheProvisionerInvoker` helper, streaming the C10 stdout/stderr lines back as DEBUG logs and parsing the final `BuildReport` JSON document the C10 process emits on stdout; (6) aggregate into `CacheBuildReport`; (7) release the lockfile in `finally`. Wraps any underlying error from C11/C10/C7/C6 as `CacheBuildError` with a `remediation` attribute populated per `failure_phase` (download phase → retry hint, key rotation hint; build phase → cache cleanup hint, GPU OOM mitigation hint). Surfaces a clear non-zero exit code via T1's `cli.py` mapping. Owns the operator-facing C12-IT-02 acceptance test contract (build_cache orchestrates C11 then C10; download failure aborts before C10; mixed reports surface in `CacheBuildReport`).
|
**Description**: Implement `BuildCacheOrchestrator`, the public top-level F1 (pre-flight cache build) workflow. `build_cache(request: BuildCacheRequest) -> CacheBuildReport` does the following sequenced work, with strict ordering: **(0) Flight-resolve phase (ADR-010, AZ-489)** — the orchestrator either calls `flights_api_client.fetch_flight(flight_id, base_url, auth_token)` (online) or `flights_api_client.load_flight_file(path)` (offline) per the resolved CLI flag, then `bbox = flights_api_client.bbox_from_waypoints(flight.waypoints, buffer_m=config.flight_bbox_buffer_m)` and `takeoff_origin = flights_api_client.takeoff_origin_from_flight(flight)`. The resolved `(bbox, takeoff_origin, flight_id, raw_flight_dto)` is captured into `FlightResolveReport` for FDR/debug and forwarded into the downstream phases; any `FlightsApiUnreachableError` / `FlightsApiAuthError` / `FlightNotFoundError` / `FlightsApiSchemaError` / `FlightFileNotFoundError` / `EmptyWaypointsError` / `WaypointSchemaError` is wrapped as `CacheBuildError(failure_phase=flight_resolve, ...)` and aborts BEFORE the lockfile is even acquired (no point holding the lock while diagnosing operator inputs). (1) acquire a filesystem lockfile at `<cache_staging_root>/.c12.lock` per description.md § 7 (prevents concurrent F1 runs from stomping each other); (2) call `tile_downloader.fetch(...)` (AZ-316) on the operator workstation with `bbox` (computed in phase 0), `sector_class`, `freshness_threshold_months`, `satellite_provider_url`, `api_key`; (3) on download `failure` outcome → wrap as `CacheBuildError(failure_phase=download, ...)` and return `CacheBuildReport(outcome=failure, failure_phase=download, flight_resolve_report=..., download_report=..., build_report=None)` WITHOUT invoking C10; (4) on download `success` → call `companion_bringup.verify_companion_ready(...)` (AZ-327) — if `not_ready` → wrap and return `CacheBuildReport(outcome=failure, failure_phase=download, ...)`; (5) SSH-invoke `C10.CacheProvisioner.build_cache_artifacts` (AZ-325) on the companion via the `RemoteCacheProvisionerInvoker` helper, **passing `takeoff_origin` + `flight_id` along with bbox/sector_class** so AZ-325 / AZ-323 bake them into the Manifest. Stream the C10 stdout/stderr lines back as DEBUG logs and parse the final `BuildReport` JSON document the C10 process emits on stdout; (6) aggregate into `CacheBuildReport`; (7) release the lockfile in `finally`. Wraps any underlying error from C11/C10/C7/C6 as `CacheBuildError` with a `remediation` attribute populated per `failure_phase`. Owns the operator-facing C12-IT-02 acceptance test contract.
|
||||||
**Complexity**: 5 points
|
**Complexity**: 5 points
|
||||||
**Dependencies**: AZ-326_c12_cli_app, AZ-327_c12_companion_bringup, AZ-316_c11_tile_downloader, AZ-325_c10_cache_provisioner, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
|
**Dependencies**: AZ-326_c12_cli_app, AZ-327_c12_companion_bringup, AZ-316_c11_tile_downloader, AZ-325_c10_cache_provisioner, AZ-489_c12_flights_api_client (Flight resolve + bbox-from-waypoints + takeoff origin), AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
|
||||||
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
||||||
**Tracker**: AZ-328
|
**Tracker**: AZ-328
|
||||||
**Epic**: AZ-253 (E-C12)
|
**Epic**: AZ-253 (E-C12)
|
||||||
@@ -34,12 +34,13 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
|||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
- A `BuildCacheOrchestrator` class at `src/operator_tool/build_cache.py`:
|
- A `BuildCacheOrchestrator` class at `src/operator_tool/build_cache.py`:
|
||||||
- Constructor: `__init__(self, *, tile_downloader: TileDownloader, companion_bringup: CompanionBringup, remote_c10_invoker: RemoteCacheProvisionerInvoker, freshness_table: FreshnessTable, lock_factory: FileLockFactory, logger: Logger, clock: Clock, config: C12BuildCacheConfig)`.
|
- Constructor: `__init__(self, *, flights_api_client: FlightsApiClient, tile_downloader: TileDownloader, companion_bringup: CompanionBringup, remote_c10_invoker: RemoteCacheProvisionerInvoker, freshness_table: FreshnessTable, lock_factory: FileLockFactory, logger: Logger, clock: Clock, config: C12BuildCacheConfig)`.
|
||||||
- `C12BuildCacheConfig` (`@dataclass(frozen=True)`): `cache_staging_root: Path`, `lock_filename: str = ".c12.lock"`, `lock_timeout_s: float = 5.0`, `companion_cache_root: PurePosixPath`.
|
- `C12BuildCacheConfig` (`@dataclass(frozen=True)`): `cache_staging_root: Path`, `lock_filename: str = ".c12.lock"`, `lock_timeout_s: float = 5.0`, `companion_cache_root: PurePosixPath`, `flight_bbox_buffer_m: float = 1000.0`, `flights_api_base_url: str`, `flights_api_auth_token: SecretStr`.
|
||||||
- Public method: `build_cache(request: BuildCacheRequest) -> CacheBuildReport`.
|
- Public method: `build_cache(request: BuildCacheRequest) -> CacheBuildReport`.
|
||||||
- DTOs at `src/operator_tool/_types.py`:
|
- DTOs at `src/operator_tool/_types.py`:
|
||||||
- `BuildCacheRequest` (`@dataclass(frozen=True)`): `area: AreaIdentifier`, `bbox: Bbox`, `sector_class: SectorClassification`, `calibration_path: Path`, `satellite_provider_url: str`, `api_key: SecretStr`, `companion_address: CompanionAddress`, `expected_engines: tuple[str, ...]`.
|
- `BuildCacheRequest` (`@dataclass(frozen=True)`): `flight_source: FlightSource (one of `FlightById(flight_id: UUID)` or `FlightFromFile(path: Path)`)`, `sector_class: SectorClassification`, `calibration_path: Path`, `satellite_provider_url: str`, `api_key: SecretStr`, `companion_address: CompanionAddress`, `expected_engines: tuple[str, ...]`. **The legacy `bbox` field is removed — the orchestrator derives bbox from the resolved `FlightDto`.**
|
||||||
- `CacheBuildReport` (`@dataclass(frozen=True)`): `download_report: DownloadBatchReport | None`, `build_report: BuildReport | None`, `outcome: enum {success, failure, idempotent_no_op}`, `failure_phase: enum {none, download, build}`, `failure_reason: str | None`, `wall_clock_s: float`.
|
- `FlightResolveReport` (`@dataclass(frozen=True)`): `source: enum {flights_api, flight_file}`, `flight_id: UUID`, `waypoint_count: int`, `bbox: Bbox`, `takeoff_origin: LatLonAlt`, `raw_flight_dto: FlightDto`.
|
||||||
|
- `CacheBuildReport` (`@dataclass(frozen=True)`): `flight_resolve_report: FlightResolveReport | None`, `download_report: DownloadBatchReport | None`, `build_report: BuildReport | None`, `outcome: enum {success, failure, idempotent_no_op}`, `failure_phase: enum {none, flight_resolve, download, build}`, `failure_reason: str | None`, `wall_clock_s: float`.
|
||||||
- Errors at `src/operator_tool/errors.py`:
|
- Errors at `src/operator_tool/errors.py`:
|
||||||
- `CacheBuildError(Exception)`: attributes `failure_phase: enum {download, build}`, `wrapped_exception_repr: str`, `remediation: str`. The `remediation` attribute is populated at construction time per `failure_phase` (download → "Re-run with same args; check `satellite_provider_url` and `api_key`."; build → "Inspect companion `~/.azaion/onboard/c10-build.log`; consider `rm -rf <companion_cache_root>/engines/` to force a clean rebuild.").
|
- `CacheBuildError(Exception)`: attributes `failure_phase: enum {download, build}`, `wrapped_exception_repr: str`, `remediation: str`. The `remediation` attribute is populated at construction time per `failure_phase` (download → "Re-run with same args; check `satellite_provider_url` and `api_key`."; build → "Inspect companion `~/.azaion/onboard/c10-build.log`; consider `rm -rf <companion_cache_root>/engines/` to force a clean rebuild.").
|
||||||
- `BuildLockHeldError(CacheBuildError)`: subclass for the lock-held case with `remediation` = "Another `build-cache` is in progress; wait or kill the holding process and remove `<lock_path>`."
|
- `BuildLockHeldError(CacheBuildError)`: subclass for the lock-held case with `remediation` = "Another `build-cache` is in progress; wait or kill the holding process and remove `<lock_path>`."
|
||||||
@@ -59,14 +60,22 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
|||||||
```
|
```
|
||||||
Concrete: `FilelockFileLockFactory` wrapping the `filelock` library per the project pin (already used by E-C13 per epics.md C13 section). NOT a custom implementation.
|
Concrete: `FilelockFileLockFactory` wrapping the `filelock` library per the project pin (already used by E-C13 per epics.md C13 section). NOT a custom implementation.
|
||||||
- Method flow for `build_cache`:
|
- Method flow for `build_cache`:
|
||||||
|
0. **Flight resolve phase** (ADR-010 / AZ-489) — runs BEFORE the lockfile is acquired:
|
||||||
|
- Branch on `request.flight_source`:
|
||||||
|
- `FlightById(flight_id)` → `flight = flights_api_client.fetch_flight(flight_id=..., base_url=config.flights_api_base_url, auth_token=config.flights_api_auth_token)`.
|
||||||
|
- `FlightFromFile(path)` → `flight = flights_api_client.load_flight_file(path=path)`.
|
||||||
|
- Compute `bbox = flights_api_client.bbox_from_waypoints(flight.waypoints, buffer_m=config.flight_bbox_buffer_m)`.
|
||||||
|
- Compute `takeoff_origin = flights_api_client.takeoff_origin_from_flight(flight)`.
|
||||||
|
- Build `FlightResolveReport(source=..., flight_id=flight.flight_id, waypoint_count=len(flight.waypoints), bbox, takeoff_origin, raw_flight_dto=flight)`.
|
||||||
|
- Catch `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError` → wrap as `CacheBuildError(failure_phase=flight_resolve, ...)` and return `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, flight_resolve_report=None, download_report=None, build_report=None, ...)`. INFO log `kind="c12.build_cache.flight_resolve.start"` before; ERROR log `kind="c12.build_cache.flight_resolve.failed"` on failure with the resolved error class name (auth_token NEVER logged).
|
||||||
1. Compute `lock_path = config.cache_staging_root / config.lock_filename`. Ensure `config.cache_staging_root` exists (mkdir parents=True).
|
1. Compute `lock_path = config.cache_staging_root / config.lock_filename`. Ensure `config.cache_staging_root` exists (mkdir parents=True).
|
||||||
2. Compute `freshness_threshold_months = freshness_table.threshold(request.sector_class)` (uses T1's helper).
|
2. Compute `freshness_threshold_months = freshness_table.threshold(request.sector_class)` (uses T1's helper).
|
||||||
3. Acquire lock: `with lock_factory.try_lock(lock_path, timeout_s=config.lock_timeout_s) as lock:` — on timeout, raise `BuildLockHeldError(failure_phase=download, ...)`.
|
3. Acquire lock: `with lock_factory.try_lock(lock_path, timeout_s=config.lock_timeout_s) as lock:` — on timeout, raise `BuildLockHeldError(failure_phase=download, ...)`.
|
||||||
4. Record `start_t = clock.monotonic()`.
|
4. Record `start_t = clock.monotonic()`.
|
||||||
5. INFO log `kind="c12.build_cache.start"` with the request (api_key REDACTED).
|
5. INFO log `kind="c12.build_cache.start"` with the request (api_key + auth_token REDACTED) and the `flight_resolve_report` summary.
|
||||||
6. **Download phase**: `download_report = tile_downloader.fetch(DownloadRequest(area=request.area, bbox=request.bbox, freshness_threshold_months=freshness_threshold_months, url=request.satellite_provider_url, api_key=request.api_key))`. Catch `SatelliteProviderError`, `RateLimitedError`, `ResolutionRejectionError`, `CacheBudgetExceededError`, `TileManagerError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `download_report.outcome == failure` → return `CacheBuildReport(outcome=failure, failure_phase=download, download_report=..., build_report=None, failure_reason=download_report.failure_reason, wall_clock_s=...)`.
|
6. **Download phase**: `download_report = tile_downloader.fetch(DownloadRequest(bbox=flight_resolve_report.bbox, freshness_threshold_months=freshness_threshold_months, url=request.satellite_provider_url, api_key=request.api_key))` — the bbox is the one derived in phase 0; the orchestrator no longer accepts a caller-supplied bbox. Catch `SatelliteProviderError`, `RateLimitedError`, `ResolutionRejectionError`, `CacheBudgetExceededError`, `TileManagerError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `download_report.outcome == failure` → return `CacheBuildReport(outcome=failure, failure_phase=download, flight_resolve_report=..., download_report=..., build_report=None, failure_reason=download_report.failure_reason, wall_clock_s=...)`.
|
||||||
7. **Verify-ready phase**: `readiness = companion_bringup.verify_companion_ready(request.companion_address)`. Catch `CompanionUnreachableError`, `ContentHashMismatchError` → wrap as `CacheBuildError(failure_phase=download, ...)` (the C11 download succeeded but the companion is not in a state to consume the new tiles; failure_phase is `download` because the operator's next action is to re-run the same `build-cache` command, not to clean the build). If `readiness.outcome == not_ready` → return `CacheBuildReport(outcome=failure, failure_phase=download, ..., failure_reason="companion not ready: " + ", ".join(readiness.not_ready_reasons))`.
|
7. **Verify-ready phase**: `readiness = companion_bringup.verify_companion_ready(request.companion_address)`. Catch `CompanionUnreachableError`, `ContentHashMismatchError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `readiness.outcome == not_ready` → return `CacheBuildReport(outcome=failure, failure_phase=download, ..., failure_reason="companion not ready: " + ", ".join(readiness.not_ready_reasons))`.
|
||||||
8. **Build phase**: open SSH session via `ssh_factory.open(request.companion_address, ...)`; call `remote_c10_invoker.invoke(session, RemoteBuildRequest(bbox=request.bbox, sector_class=request.sector_class, calibration_path=request.calibration_path, expected_engines=request.expected_engines, companion_cache_root=config.companion_cache_root))`; catch `EngineBuildError`, `CalibrationCacheError`, `ManifestSignatureError`, `ManifestCoverageError`, `BuildLockHeldError` (C10's lock, distinct from C12's) → wrap as `CacheBuildError(failure_phase=build, ...)`.
|
8. **Build phase**: open SSH session via `ssh_factory.open(request.companion_address, ...)`; call `remote_c10_invoker.invoke(session, RemoteBuildRequest(bbox=flight_resolve_report.bbox, zoom_levels=..., sector_class=request.sector_class, calibration_path=request.calibration_path, expected_engines=request.expected_engines, companion_cache_root=config.companion_cache_root, takeoff_origin=flight_resolve_report.takeoff_origin, flight_id=flight_resolve_report.flight_id))` — the orchestrator forwards `takeoff_origin` + `flight_id` to the remote C10 build entry point so AZ-325 / AZ-323 bake them into the Manifest (ADR-010, AZ-490 consumes them on the companion at boot). Catch `EngineBuildError`, `CalibrationCacheError`, `ManifestSignatureError`, `ManifestCoverageError`, `BuildLockHeldError` (C10's lock, distinct from C12's) → wrap as `CacheBuildError(failure_phase=build, ...)`.
|
||||||
9. Aggregate: `build_report` from step 8. If `build_report.outcome == IDEMPOTENT_NO_OP` → return `CacheBuildReport(outcome=idempotent_no_op, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`. Else if `build_report.outcome == FAILURE` → return `CacheBuildReport(outcome=failure, failure_phase=build, ..., failure_reason=build_report.failure_reason, ...)`.
|
9. Aggregate: `build_report` from step 8. If `build_report.outcome == IDEMPOTENT_NO_OP` → return `CacheBuildReport(outcome=idempotent_no_op, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`. Else if `build_report.outcome == FAILURE` → return `CacheBuildReport(outcome=failure, failure_phase=build, ..., failure_reason=build_report.failure_reason, ...)`.
|
||||||
10. INFO log `kind="c12.build_cache.success"` with the aggregated counts (tiles_downloaded, engines_built, engines_reused, descriptors_generated).
|
10. INFO log `kind="c12.build_cache.success"` with the aggregated counts (tiles_downloaded, engines_built, engines_reused, descriptors_generated).
|
||||||
11. Return `CacheBuildReport(outcome=success, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`.
|
11. Return `CacheBuildReport(outcome=success, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`.
|
||||||
@@ -99,10 +108,10 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
**AC-1: Happy path — download → verify-ready → build → `success`**
|
**AC-1: Happy path — flight-resolve → download → verify-ready → build → `success`**
|
||||||
Given a fresh empty C6 + a clean companion + valid `BuildCacheRequest` + fakes that all return `success`
|
Given a fresh empty C6 + a clean companion + valid `BuildCacheRequest(flight_source=FlightById(...))` + fakes that all return `success` (including a 3-waypoint `FlightDto`)
|
||||||
When `build_cache(request)` is called
|
When `build_cache(request)` is called
|
||||||
Then the call sequence is `lock acquire → tile_downloader.fetch → companion_bringup.verify_companion_ready → remote_c10_invoker.invoke → lock release` (verifiable via spy on each fake); `CacheBuildReport(outcome=success, failure_phase=none, download_report=..., build_report=..., failure_reason=None)` is returned; ONE INFO log `kind="c12.build_cache.start"`; ONE INFO log `kind="c12.build_cache.success"`
|
Then the call sequence is `flights_api_client.fetch_flight → bbox_from_waypoints → takeoff_origin_from_flight → lock acquire → tile_downloader.fetch (with derived bbox) → companion_bringup.verify_companion_ready → remote_c10_invoker.invoke (with takeoff_origin + flight_id) → lock release` (verifiable via spy on each fake); `CacheBuildReport(outcome=success, failure_phase=none, flight_resolve_report=..., download_report=..., build_report=..., failure_reason=None)` is returned; ONE INFO log `kind="c12.build_cache.flight_resolve.start"`; ONE INFO log `kind="c12.build_cache.start"`; ONE INFO log `kind="c12.build_cache.success"`
|
||||||
|
|
||||||
**AC-2: Download failure aborts before C10**
|
**AC-2: Download failure aborts before C10**
|
||||||
Given a fake `tile_downloader.fetch` that raises `SatelliteProviderError("503 Service Unavailable")`
|
Given a fake `tile_downloader.fetch` that raises `SatelliteProviderError("503 Service Unavailable")`
|
||||||
@@ -144,10 +153,35 @@ Given a `BuildCacheRequest` with `api_key=SecretStr("super-secret-token")`
|
|||||||
When any log line is emitted by the orchestrator
|
When any log line is emitted by the orchestrator
|
||||||
Then no log line contains the literal token; `api_key` field appears as `"REDACTED"` or is omitted entirely
|
Then no log line contains the literal token; `api_key` field appears as `"REDACTED"` or is omitted entirely
|
||||||
|
|
||||||
**AC-10: Aggregated `CacheBuildReport` carries both sub-reports on success**
|
**AC-10: Aggregated `CacheBuildReport` carries all sub-reports on success**
|
||||||
Given a happy-path run
|
Given a happy-path run
|
||||||
When the caller inspects the returned `CacheBuildReport`
|
When the caller inspects the returned `CacheBuildReport`
|
||||||
Then `download_report` is a populated `DownloadBatchReport` from C11; `build_report` is a populated `BuildReport` from C10; `wall_clock_s` is a positive float; both sub-reports' fields are accessible (no truncation)
|
Then `flight_resolve_report` is a populated `FlightResolveReport`; `download_report` is a populated `DownloadBatchReport` from C11; `build_report` is a populated `BuildReport` from C10; `wall_clock_s` is a positive float; all sub-reports' fields are accessible (no truncation)
|
||||||
|
|
||||||
|
**AC-11: Flight-resolve failure aborts BEFORE the lockfile (ADR-010)**
|
||||||
|
Given `flights_api_client.fetch_flight` raises `FlightNotFoundError`
|
||||||
|
When `build_cache(request)` is called
|
||||||
|
Then `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, flight_resolve_report=None, download_report=None, build_report=None, failure_reason="flight not found: <uuid>")` is returned; `lock_factory.try_lock` is NEVER called; `tile_downloader.fetch` is NEVER called; `companion_bringup.verify_companion_ready` is NEVER called; `remote_c10_invoker.invoke` is NEVER called; ONE ERROR log `kind="c12.build_cache.flight_resolve.failed"`
|
||||||
|
|
||||||
|
**AC-12: Offline flight-file path used when `FlightFromFile` source is passed**
|
||||||
|
Given `BuildCacheRequest(flight_source=FlightFromFile(path=/tmp/flight.json))`
|
||||||
|
When `build_cache(request)` is called
|
||||||
|
Then `flights_api_client.load_flight_file(path=/tmp/flight.json)` is called once; `flights_api_client.fetch_flight` is NEVER called; the rest of the pipeline runs identically
|
||||||
|
|
||||||
|
**AC-13: `takeoff_origin` is forwarded to the remote C10 invoker**
|
||||||
|
Given a fake `FlightDto` with `waypoints[0] = (50.0, 36.2, 200.0)`
|
||||||
|
When `build_cache(request)` is called through to the build phase
|
||||||
|
Then `remote_c10_invoker.invoke` is called with `RemoteBuildRequest.takeoff_origin == LatLonAlt(50.0, 36.2, 200.0)` and `RemoteBuildRequest.flight_id == flight.flight_id`
|
||||||
|
|
||||||
|
**AC-14: `EmptyWaypointsError` surfaces with `failure_phase=flight_resolve`**
|
||||||
|
Given the resolved `FlightDto` has zero waypoints (so `bbox_from_waypoints` raises `EmptyWaypointsError`)
|
||||||
|
When `build_cache(request)` is called
|
||||||
|
Then `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, ..., failure_reason="empty waypoints; re-plan in Mission Planner UI")` is returned; lockfile NOT acquired
|
||||||
|
|
||||||
|
**AC-15: `auth_token` is REDACTED in all log output (Phase 0)**
|
||||||
|
Given `config.flights_api_auth_token = SecretStr("bearer-xyz")`
|
||||||
|
When any log line is emitted by the flight-resolve phase
|
||||||
|
Then no log line contains the literal `bearer-xyz`; the field appears as `"REDACTED"` or is omitted entirely (same convention as AC-9 for `api_key`)
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
@@ -178,13 +212,18 @@ Then `download_report` is a populated `DownloadBatchReport` from C11; `build_rep
|
|||||||
| AC-7 | Fake C10 returns `IDEMPOTENT_NO_OP` | `outcome=idempotent_no_op`, INFO log |
|
| AC-7 | Fake C10 returns `IDEMPOTENT_NO_OP` | `outcome=idempotent_no_op`, INFO log |
|
||||||
| AC-8 | Construct each error type, inspect `remediation` | Matches documented text per phase |
|
| AC-8 | Construct each error type, inspect `remediation` | Matches documented text per phase |
|
||||||
| AC-9 | Capture log output with `api_key="super-secret-token"` | Token not present in any log line |
|
| AC-9 | Capture log output with `api_key="super-secret-token"` | Token not present in any log line |
|
||||||
| AC-10 | Happy-path inspect returned report | Both sub-reports present, fields accessible |
|
| AC-10 | Happy-path inspect returned report | All three sub-reports (flight_resolve + download + build) present, fields accessible |
|
||||||
|
| AC-11 | Fake `fetch_flight` raises `FlightNotFoundError` | `failure_phase=flight_resolve`; lockfile NOT acquired; ZERO downstream calls |
|
||||||
|
| AC-12 | `FlightFromFile` source | `load_flight_file` called; `fetch_flight` NOT called |
|
||||||
|
| AC-13 | Inspect `RemoteBuildRequest` sent to invoker | `takeoff_origin` + `flight_id` forwarded |
|
||||||
|
| AC-14 | `EmptyWaypointsError` from `bbox_from_waypoints` | `failure_phase=flight_resolve`; lockfile NOT acquired |
|
||||||
|
| AC-15 | Capture log output with auth_token | Token not present |
|
||||||
| NFR-perf-overhead | Microbench orchestrator-only path with all-fake collaborators × 100 | p99 ≤ 50 ms (excludes real network/SSH) |
|
| NFR-perf-overhead | Microbench orchestrator-only path with all-fake collaborators × 100 | p99 ≤ 50 ms (excludes real network/SSH) |
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- Strict phase ordering is non-negotiable: download → verify-ready → build. Any reordering breaks AC-2/AC-3 and causes operators to chase phantom errors.
|
- Strict phase ordering is non-negotiable: flight_resolve → lock → download → verify-ready → build. Any reordering breaks AC-2/AC-3/AC-11 and causes operators to chase phantom errors. **The flight_resolve phase happens BEFORE the lockfile is acquired — a Flight that cannot be resolved is an operator-input error, not a contended-resource error, and should not block parallel builds.**
|
||||||
- `failure_phase` is a closed set `{none, download, build}` — adding a new value requires Plan-cycle approval (operators script against these values).
|
- `failure_phase` is a closed set `{none, flight_resolve, download, build}` — adding a new value requires Plan-cycle approval (operators script against these values).
|
||||||
- The lockfile lives in the operator workstation's cache staging area, NOT on the companion. Companion-side concurrent protection is C10's responsibility (CP-INV-4 in AZ-325).
|
- The lockfile lives in the operator workstation's cache staging area, NOT on the companion. Companion-side concurrent protection is C10's responsibility (CP-INV-4 in AZ-325).
|
||||||
- `api_key` field uses `pydantic.SecretStr` (or equivalent) and MUST NOT be `repr()`-logged anywhere in the orchestrator.
|
- `api_key` field uses `pydantic.SecretStr` (or equivalent) and MUST NOT be `repr()`-logged anywhere in the orchestrator.
|
||||||
- The remote C10 invocation goes through the same `SshSessionFactory` as T2 — do NOT instantiate a second SSH client. Single composition root.
|
- The remote C10 invocation goes through the same `SshSessionFactory` as T2 — do NOT instantiate a second SSH client. Single composition root.
|
||||||
|
|||||||
@@ -1,57 +1,67 @@
|
|||||||
# FT-P-11 — Cold-start initialization from FC EKF
|
# FT-P-11 — Cold-start initialization from operator origin (primary) OR FC EKF (secondary)
|
||||||
|
|
||||||
**Task**: AZ-419_ft_p_11_cold_start_init
|
**Task**: AZ-419_ft_p_11_cold_start_init
|
||||||
**Name**: Cold-start initialization from FC EKF's last valid GPS + IMU-extrapolated position (AC-5.1)
|
**Name**: Cold-start initialization — operator-origin-from-Manifest primary; FC EKF GPS secondary (ADR-010 + AC-5.1)
|
||||||
**Description**: Implement FT-P-11 — start SITL with `cold-boot-fixture` snapshot loaded; start SUT cold; push first nav-camera frame; assert first outbound estimate's lat/lon within ±50 m of the FC EKF snapshot pose.
|
**Description**: Implement FT-P-11 — exercise both cold-start paths defined by ADR-010. **Primary path (AZ-490)**: pre-bake a `takeoff_origin` into the C10 Manifest, start SUT cold, push first nav-camera frame, assert the first outbound estimate's lat/lon falls within ±50 m of the operator origin even when the SITL FC EKF reports NO valid GPS. **Secondary path (legacy AC-5.1)**: clear the Manifest's `takeoff_origin`, load a `cold-boot-fixture` snapshot into SITL, start SUT cold, push first nav-camera frame, assert the first outbound estimate's lat/lon falls within ±50 m of the FC EKF snapshot. The two paths share a single test module parameterised on `(origin_source ∈ {operator_manifest, fc_ekf})`. The test also exercises the bounded-delta gate (Principle #11 amended): set Manifest origin to A and SITL FC EKF to a position B with `|A − B| > 200 m`; assert the operator origin wins and the FC GPS is logged as suspect.
|
||||||
**Complexity**: 3 points
|
**Complexity**: 3 points
|
||||||
**Dependencies**: AZ-406, AZ-407 (cold-boot-fixture)
|
**Dependencies**: AZ-406, AZ-407 (cold-boot-fixture), AZ-323 / AZ-325 (Manifest with takeoff_origin), AZ-490 (set_takeoff_origin), AZ-489 (FlightsApiClient — used by the test fixture builder to fabricate Manifests with a known origin)
|
||||||
**Component**: Blackbox Tests / Positive / Startup (epic AZ-262)
|
**Component**: Blackbox Tests / Positive / Startup (epic AZ-262)
|
||||||
**Tracker**: AZ-419
|
**Tracker**: AZ-419
|
||||||
**Epic**: AZ-262 (E-BBT)
|
**Epic**: AZ-262 (E-BBT)
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Cold-start initialization is a critical path — if the SUT cannot bootstrap its prior from the FC's last valid GPS + IMU-extrapolated pose (AC-5.1), it cannot resume after companion reboot or after a cold boot in a flight-resume scenario. This must be measured.
|
Cold-start initialization is a critical path. The original assumption that the FC EKF's last valid GPS fix is always available at takeoff (AC-5.1) does not hold under realistic EW conditions — a UAV can be jammed at the launch site before takeoff, leaving the FC EKF with no valid GPS. ADR-010 introduces the operator-planned mission as the **primary** cold-start trust anchor: the operator authors the route in the Mission Planner UI, C12 fetches the `Flight`, derives `takeoff_origin` from `waypoints[0]`, and bakes it into the C10 Manifest. The airborne C5's `set_takeoff_origin` (AZ-490) consumes it before any sensor sample. The FC EKF GPS becomes the **secondary** path used only when the Manifest carries no origin (back-compat). Both paths must be measured end-to-end, and the bounded-delta gate (the third clause of the spoof-promotion gate, Principle #11) must be exercised so an inconsistent FC GPS at takeoff does not silently override the operator origin.
|
||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
- pytest scenario at `e2e/tests/positive/test_ft_p_11_cold_start_init.py`.
|
- pytest scenario at `e2e/tests/positive/test_ft_p_11_cold_start_init.py`.
|
||||||
- Loads `cold-boot-fixture` JSON pose into SITL (parameter-load path); starts SITL.
|
- Parameterised on `origin_source ∈ {operator_manifest, fc_ekf, bounded_delta_conflict}`:
|
||||||
- Starts SUT (cold — no prior state).
|
- **`operator_manifest`** (primary path, AZ-490): the test fixture builder writes a Manifest with `flight.takeoff_origin = A` (a known `LatLonAlt`); SITL starts with NO valid GPS (`GPS_TYPE = 0` or simulated denial); SUT cold-starts; the test asserts the first outbound estimate's lat/lon is within ±50 m of `A`.
|
||||||
- Pushes a single first nav-camera frame.
|
- **`fc_ekf`** (secondary path, legacy AC-5.1): Manifest has no `flight.takeoff_origin`; `cold-boot-fixture` JSON pose loaded into SITL (parameter-load path); SUT cold-starts; the test asserts the first outbound estimate's lat/lon is within ±50 m of the FC-EKF snapshot pose.
|
||||||
- Reads the first outbound estimate; computes Vincenty distance to the FC-EKF snapshot pose.
|
- **`bounded_delta_conflict`** (ADR-010 Principle #11 amended): Manifest carries `takeoff_origin = A`; SITL FC EKF reports `B` with `vincenty(A, B) > 200 m`; the test asserts the first outbound estimate falls within ±50 m of `A` (operator origin wins), the source label on the first estimate is NOT `SATELLITE_ANCHORED` (no immediate spoof-promotion), and the FDR carries a `c5.gps_bounded_delta.reject` record naming both A and B.
|
||||||
- Asserts distance ≤ 50 m.
|
- Starts SUT (cold — no prior FDR, no in-memory state). Pushes a single first nav-camera frame. Reads the first outbound estimate; computes Vincenty distance to the expected origin.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
### Included
|
### Included
|
||||||
- Cold-boot SITL parameter load.
|
- Cold-boot SITL parameter load (secondary path).
|
||||||
|
- Test fixture builder that produces a C10 Manifest with a known `flight.takeoff_origin` (primary path); reuses AZ-323's canonical JSON serialization.
|
||||||
- SUT cold start (`docker compose up gps-denied-onboard` from clean state; OR `systemctl start` on Tier-2).
|
- SUT cold start (`docker compose up gps-denied-onboard` from clean state; OR `systemctl start` on Tier-2).
|
||||||
- First-frame push and first-emission read.
|
- First-frame push and first-emission read.
|
||||||
- Distance comparison.
|
- Distance comparison.
|
||||||
|
- FDR record assertions for the bounded-delta conflict scenario.
|
||||||
|
|
||||||
### Excluded
|
### Excluded
|
||||||
- Cold-start TTFF latency — owned by NFT-PERF-03 (AZ-430).
|
- Cold-start TTFF latency — owned by NFT-PERF-03 (AZ-430).
|
||||||
- Companion mid-flight reboot — owned by NFT-RES-02 (AZ-433).
|
- Companion mid-flight reboot — owned by NFT-RES-02 (AZ-433).
|
||||||
|
- Mid-flight bounded-delta gate (only the takeoff slice is covered here; mid-flight is part of AZ-385 follow-up).
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
|
||||||
**AC-1: SITL reflects snapshot pose**
|
**AC-1: Primary path (operator origin) — SUT cold-starts even when FC EKF has no GPS (ADR-010)**
|
||||||
Given `cold-boot-fixture` loaded
|
Given a C10 Manifest with `flight.takeoff_origin = LatLonAlt(50.0, 36.2, 200.0)` AND SITL configured with no valid GPS
|
||||||
Then the SITL EKF reports the snapshot pose (within ±1 m per fixture's load tolerance).
|
When SUT cold-starts and the first nav-camera frame is pushed
|
||||||
|
Then the SUT emits its first outbound message within ≤30 s; `vincenty(estimate.position, manifest.takeoff_origin) ≤ 50 m`; the FDR carries a `c5.cold_start_origin.set` record with `source = "manifest"`
|
||||||
|
|
||||||
**AC-2: SUT initializes from FC EKF**
|
**AC-2: Secondary path (FC EKF) — Manifest has no origin (back-compat)**
|
||||||
Given SUT cold-started against the loaded SITL
|
Given a C10 Manifest with no `flight.takeoff_origin` AND `cold-boot-fixture` JSON loaded into SITL
|
||||||
When the first nav-camera frame is pushed
|
When SUT cold-starts
|
||||||
Then the SUT emits its first outbound message within ≤30 s (AC-NEW-1 budget — but FT-P-11 itself has a relaxed 60 s timeout).
|
Then the first outbound estimate is within ±50 m of the FC EKF snapshot; the FDR carries a `c5.cold_start_origin.set` record with `source = "fc_ekf"`
|
||||||
|
|
||||||
**AC-3: first-emission position within budget**
|
**AC-3: No origin available — SUT refuses takeoff**
|
||||||
Given the first outbound estimate
|
Given a C10 Manifest with no `flight.takeoff_origin` AND SITL with no valid GPS
|
||||||
Then `vincenty(estimate_position, snapshot_position) ≤ 50 m`.
|
When SUT cold-starts
|
||||||
|
Then NO outbound `EmittedExternalPosition` is produced within the AC-NEW-1 30 s budget; the SUT logs `c5.cold_start_origin.unavailable` to FDR + GCS STATUSTEXT; the test asserts the FT-P-11 takeoff-abort policy fires
|
||||||
|
|
||||||
**AC-4: parameterization**
|
**AC-4: Bounded-delta conflict — operator origin wins (ADR-010 Principle #11 amended)**
|
||||||
Given conftest parameterization
|
Given Manifest `takeoff_origin = A` AND SITL FC EKF reports `B` with `vincenty(A, B) > 200 m`
|
||||||
Then the scenario runs per `(fc_adapter, vio_strategy)`.
|
When SUT cold-starts and the first nav-camera frame is pushed
|
||||||
|
Then the first outbound estimate is within ±50 m of `A`; the source label is NOT `SATELLITE_ANCHORED` (no immediate spoof-promotion); the FDR carries a `c5.gps_bounded_delta.reject` record naming both A and B and the computed distance
|
||||||
|
|
||||||
|
**AC-5: parameterization across FC adapters + VIO strategies**
|
||||||
|
Given conftest parameterization on `(fc_adapter, vio_strategy, origin_source)`
|
||||||
|
Then each combination listed in the test matrix runs the appropriate ACs (AC-1 / AC-2 / AC-3 / AC-4 per `origin_source`)
|
||||||
|
|
||||||
## System Under Test Boundary
|
## System Under Test Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Batch 21 — AZ-489 C12 FlightsApiClient
|
||||||
|
|
||||||
|
**Date**: 2026-05-12
|
||||||
|
**Tracker**: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done.
|
||||||
|
**Cycle**: 1
|
||||||
|
**Status**: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips).
|
||||||
|
|
||||||
|
## Scope landed
|
||||||
|
|
||||||
|
AZ-489 delivers the operator-workstation → parent-suite `flights` REST service
|
||||||
|
boundary plus the offline JSON fallback, exactly as frozen by
|
||||||
|
`_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md`
|
||||||
|
v1.0.0. ADR-010's primary cold-start path now has a real source for both the
|
||||||
|
cache bbox and the takeoff origin.
|
||||||
|
|
||||||
|
The implementation is strictly scoped to AZ-489 — the CLI flag plumbing
|
||||||
|
(AZ-326), the orchestrator phase 0 (AZ-328), and the
|
||||||
|
`OperatorToolServices` aggregate dataclass all stay out of scope.
|
||||||
|
|
||||||
|
### Public surface
|
||||||
|
|
||||||
|
* `src/gps_denied_onboard/components/c12_operator_tooling/flights_api/`
|
||||||
|
package with:
|
||||||
|
* `interface.py` — `FlightsApiClient` Protocol (`@runtime_checkable`) +
|
||||||
|
`FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`.
|
||||||
|
DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the
|
||||||
|
project's `LatLonAlt` / `PoseEstimate` pattern.
|
||||||
|
* `errors.py` — exception hierarchy: `FlightsApiError` (base) →
|
||||||
|
`FlightsApiUnreachableError`, `FlightsApiAuthError`,
|
||||||
|
`FlightNotFoundError`, `FlightsApiSchemaError`,
|
||||||
|
`FlightFileNotFoundError`, `EmptyWaypointsError`,
|
||||||
|
`WaypointSchemaError`.
|
||||||
|
* `_parser.py` — shared JSON-payload → `FlightDto` validator used by
|
||||||
|
both online and offline paths so they satisfy FAC-INV-1 (same shape,
|
||||||
|
same error types).
|
||||||
|
* `bbox.py` — `bbox_from_waypoints(waypoints, *, buffer_m=1000.0)`
|
||||||
|
envelopes lat/lon and inflates by a horizontal-distance buffer via
|
||||||
|
`WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt`
|
||||||
|
(FAC-INV-3); `takeoff_origin_from_flight(flight)` passes
|
||||||
|
`waypoints[0]` through without rounding (FAC-INV-4).
|
||||||
|
* `file_loader.py` — `load_flight_file(*, path)` reads JSON via
|
||||||
|
`orjson`, delegates to the parser; raises
|
||||||
|
`FlightFileNotFoundError` / `FlightsApiSchemaError` /
|
||||||
|
`WaypointSchemaError` as documented.
|
||||||
|
* `httpx_client.py` — `HttpxFlightsApiClient` against the parent-suite
|
||||||
|
REST API. Single retry on transient 5xx + connection errors per
|
||||||
|
FAC-INV-5. Token redaction enforced at every log site. `transport`
|
||||||
|
+ `sleep` are constructor-injectable for unit tests.
|
||||||
|
* `src/gps_denied_onboard/runtime_root/c12_factory.py` —
|
||||||
|
`build_flights_api_client(config) -> FlightsApiClient`. Tiny factory:
|
||||||
|
one concrete strategy; httpx's defaults already enforce TLS verify ON
|
||||||
|
and the system trust store.
|
||||||
|
* `src/gps_denied_onboard/runtime_root/__init__.py` —
|
||||||
|
`build_flights_api_client` re-exported alongside the other
|
||||||
|
`build_*` factories.
|
||||||
|
* `src/gps_denied_onboard/components/c12_operator_tooling/__init__.py`
|
||||||
|
— flights_api surface re-exported as the C12 public API.
|
||||||
|
* `pyproject.toml` — `httpx>=0.28,<1.0` added to main deps.
|
||||||
|
|
||||||
|
### HTTP client choice
|
||||||
|
|
||||||
|
Selected `httpx` over `requests` (user-confirmed). Rationale:
|
||||||
|
|
||||||
|
* `httpx.MockTransport` is part of httpx — no `responses` /
|
||||||
|
`requests-mock` test dep required.
|
||||||
|
* No prior C12 / C11 HTTP code yet uses `requests`; the only listing
|
||||||
|
is a pyproject.toml dep entry without a consumer. Picking `httpx`
|
||||||
|
costs one new dep but avoids two (httpx vs requests + responses).
|
||||||
|
* TLS verify is on by default; the constructor does not accept a
|
||||||
|
`verify=False` toggle from config.
|
||||||
|
|
||||||
|
The AZ-489 spec doc + the C12 description + the flights_api_client
|
||||||
|
contract already reference `httpx`; no design drift was introduced
|
||||||
|
by this choice.
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
`tests/unit/c12_operator_tooling/test_az489_flights_api_client.py` — 28
|
||||||
|
tests across AC-1..AC-18 plus extra coverage.
|
||||||
|
|
||||||
|
| AC | Test focus | Outcome |
|
||||||
|
|----|-----------|---------|
|
||||||
|
| AC-1 | Online 200 + 3-waypoint payload → `FlightDto`; ONE INFO log; ZERO token leak | green |
|
||||||
|
| AC-2 | Online 404 → `FlightNotFoundError`; transport hit once; no retry | green |
|
||||||
|
| AC-3 | Online 401 → `FlightsApiAuthError`; no retry; token absent from logs and exception text | green |
|
||||||
|
| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN `c12.flights.fetch.retry` | green |
|
||||||
|
| AC-5 | Online 503 always → `FlightsApiUnreachableError` after ONE retry | green |
|
||||||
|
| AC-6 | Missing `lat_deg` in waypoint payload → `WaypointSchemaError` naming the field | green |
|
||||||
|
| AC-7 | Offline well-formed JSON → equivalent `FlightDto` | green |
|
||||||
|
| AC-8 | Offline missing file → `FlightFileNotFoundError` with path in message | green |
|
||||||
|
| AC-9 | `bbox_from_waypoints(())` → `EmptyWaypointsError` | green |
|
||||||
|
| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green |
|
||||||
|
| AC-11 | `takeoff_origin_from_flight` returns `waypoints[0]` byte-equal (no rounding) | green |
|
||||||
|
| AC-12 | `isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True`; same for factory output | green |
|
||||||
|
| AC-13 | Online + offline DTOs from the same payload compare equal | green |
|
||||||
|
| AC-14 | Shuffled ordinals `[2, 0, 1]` returned sorted | green |
|
||||||
|
| AC-15 | Ordinal gap `[0, 1, 3]` → `WaypointSchemaError` | green |
|
||||||
|
| AC-16 | `lat_deg=200` → `WaypointSchemaError` naming the field + value | green |
|
||||||
|
| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green |
|
||||||
|
| AC-18 | Persistent `httpx.ConnectError` → `FlightsApiUnreachableError` after one 1 s retry | green |
|
||||||
|
| Bonus | Malformed JSON file → `FlightsApiSchemaError` | green |
|
||||||
|
| Bonus | Negative `buffer_m` → `ValueError` | green |
|
||||||
|
| Bonus | Zero `buffer_m` → envelope-only bbox; matches expected within 1% | green |
|
||||||
|
| Bonus | Missing top-level `flight_id` in JSON file → `FlightsApiSchemaError` | green |
|
||||||
|
| Bonus | Negative ordinal → `WaypointSchemaError` | green |
|
||||||
|
| Bonus | `takeoff_origin_from_flight(empty_flight)` → `EmptyWaypointsError` | green |
|
||||||
|
|
||||||
|
Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20.
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
* `ruff check` on every changed file — clean (fixed: `UP037` quoted
|
||||||
|
annotation in c12_factory; `RUF100` unused `noqa` in test file).
|
||||||
|
* `ReadLints` on the changed surface — no diagnostics.
|
||||||
|
* Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips
|
||||||
|
for `cmake` and `actionlint` in CI-only scaffold tests).
|
||||||
|
|
||||||
|
## Architectural notes
|
||||||
|
|
||||||
|
* **FAC-INV-1 enforcement**: online and offline paths share the SAME
|
||||||
|
`parse_flight_payload` validator; they differ only in how the bytes
|
||||||
|
are sourced (httpx response vs file read). The AC-13 equality test
|
||||||
|
is the visible proof.
|
||||||
|
* **FAC-INV-3 horizontal-distance buffer**: implemented as a local-ENU
|
||||||
|
round-trip centred at the bbox envelope's midpoint. Picking the
|
||||||
|
midpoint as the ENU origin (NOT one of the corners) keeps the
|
||||||
|
inflation symmetric at high latitudes; the AC-10 test exercises 50° N
|
||||||
|
and validates against the metres-per-degree expectations.
|
||||||
|
* **FAC-INV-5 retry semantics**: the implementation issues at most ONE
|
||||||
|
retry — on transient 5xx OR connection error. 401/403/404 + schema
|
||||||
|
errors are never retried. The retry path emits a single WARN log
|
||||||
|
`c12.flights.fetch.retry`; the test fixture injects a fake `sleep`
|
||||||
|
to avoid the real 1 s backoff while still asserting the timing.
|
||||||
|
* **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs
|
||||||
|
the auth_token in any code path. Every structured log emits the
|
||||||
|
literal `"<redacted>"`. The AC-17 parametrised test exercises happy,
|
||||||
|
401, 404, and 500 paths and asserts the token literal is absent from
|
||||||
|
the captured log buffer.
|
||||||
|
|
||||||
|
## Cross-task notes
|
||||||
|
|
||||||
|
* **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489
|
||||||
|
spec mentions extending an `OperatorToolServices` dataclass at
|
||||||
|
`runtime_root/c12_factory.py`, but that dataclass doesn't exist yet
|
||||||
|
— it's part of AZ-328's territory (the build-cache orchestrator).
|
||||||
|
Per scope-discipline this batch ships ONLY the `build_flights_api_client`
|
||||||
|
factory function; AZ-328 will create the aggregate and wire the
|
||||||
|
flights client through it.
|
||||||
|
* **AZ-326 (CLI flag plumbing)** can now consume the new
|
||||||
|
`FlightsApiClient` and `--flight-file` path; the CLI must validate
|
||||||
|
the `--flight-id` vs `--flight-file` exclusivity and translate
|
||||||
|
`FlightsApiError` subclasses to the documented exit codes.
|
||||||
|
* **AZ-328 (build-cache orchestrator)** can now wire the flight-resolve
|
||||||
|
phase 0 against the new client + DTOs.
|
||||||
|
* **AZ-419 (FT-P-11 cold-start scenario)** has both forward deps
|
||||||
|
resolved on this side (AZ-489 done) and waits on AZ-490 for the C5
|
||||||
|
`set_takeoff_origin` entrypoint.
|
||||||
|
|
||||||
|
## Tracker
|
||||||
|
|
||||||
|
* Jira AZ-489 transitioned To Do → In Progress → Done.
|
||||||
|
* Comment added on AZ-489 summarising deliverables + deviations from
|
||||||
|
the original Jira description (Jira description predates the AZ-489
|
||||||
|
spec doc; the doc is the authoritative source).
|
||||||
|
* AZ-489 spec file moved from `_docs/02_tasks/todo/` to
|
||||||
|
`_docs/02_tasks/done/`.
|
||||||
@@ -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]
|
||||||
|
```
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Cumulative Code Review — Batches 01-22 (Cycle 1)
|
||||||
|
|
||||||
|
**Batches**: 01 through 22 (catch-up review after 7 missed cumulative-review triggers)
|
||||||
|
**Date**: 2026-05-12
|
||||||
|
**Cycle**: 1
|
||||||
|
**Scope**: union of files changed since project start (no prior cumulative review)
|
||||||
|
**Mode**: cumulative (per `code-review/SKILL.md` § Invocation modes)
|
||||||
|
**Architecture baseline**: `_docs/02_document/architecture_compliance_baseline.md` does not exist — no Baseline Delta section emitted.
|
||||||
|
|
||||||
|
**Initial verdict**: **FAIL** (one HIGH-severity Architecture finding affecting cross-task wiring of C4 ↔ C5).
|
||||||
|
**Post-remediation verdict**: **PASS** — F1 and F2 fixed in the same session; F3 is informational.
|
||||||
|
|
||||||
|
## Process Context
|
||||||
|
|
||||||
|
`implement/SKILL.md` Step 14.5 mandates a cumulative cross-batch review every K=3 completed batches. Across batches 01-22, no `cumulative_review_*` artifact exists; this report consolidates 22 batches' worth of accumulated structural drift in one pass. The 7 missed triggers (batches 3, 6, 9, 12, 15, 18, 21) were each individually low-value relative to this single end-to-end pass; the meta-issue is procedural and is captured as F3 below.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Severity | Category | File:Line | Title | Status |
|
||||||
|
|---|----------|-------------|----------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|--------|
|
||||||
|
| 1 | High | Architecture | `src/gps_denied_onboard/components/c5_state/_isam2_handle.py:ISam2GraphHandleImpl` | `ISam2GraphHandleImpl` missing `get_pose_key`, breaks the C4 consumer-side `isinstance` gate | FIXED |
|
||||||
|
| 2 | Medium | Maintainability | 4 sites (see table) | 4 `except: pass` sites in production code without justifying comments | FIXED |
|
||||||
|
| 3 | Low | Process | `_docs/03_implementation/` | 7 cumulative-review triggers (batches 3, 6, 9, 12, 15, 18, 21) were skipped before this run | NOTED |
|
||||||
|
|
||||||
|
### F1: `ISam2GraphHandleImpl` is missing `get_pose_key`, breaking the C4 consumer-side `isinstance` gate (High / Architecture)
|
||||||
|
|
||||||
|
**Design intent (NOT the bug)**
|
||||||
|
|
||||||
|
Two `ISam2GraphHandle` Protocols live in the codebase **intentionally**, and the design is correct:
|
||||||
|
|
||||||
|
| File | Owner | Role | Methods |
|
||||||
|
|-------------------------------------------------------|---------|-------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
|
||||||
|
| `components/c4_pose/_isam2_handle.py` | C4 | Consumer-side minimal cut — declares only what C4 needs to attach pose factors | `get_pose_key(frame_id: int) -> int` |
|
||||||
|
| `components/c5_state/_isam2_handle.py` | C5 | Producer-side richer surface — declares what C5 (and only C5) calls on its own iSAM2 graph internals | `add_factor`, `update`, `compute_marginals`, `last_anchor_age_ms` |
|
||||||
|
|
||||||
|
Per AZ-355 Risk-1 mitigation: "the stub defines ONLY `get_pose_key(frame_id) -> int` — the minimal surface C4 needs to attach factors. C5 (AZ-260/AZ-381) implements the concrete handle; if C5's graph design grows, the stub may grow but the Protocol surface stays stable as long as C4's needs don't change." Two consumer-side Protocols over a single concrete impl is the intended seam — neither component imports the other's internals.
|
||||||
|
|
||||||
|
**The bug**
|
||||||
|
|
||||||
|
The c4 docstring claims `ISam2GraphHandleImpl` "is strictly a superset" of the C4 surface. That claim was **provably false** before this fix: `ISam2GraphHandleImpl` did not implement `get_pose_key`, so `isinstance(c5_handle, c4.ISam2GraphHandle)` returned **False**.
|
||||||
|
|
||||||
|
Composition impact:
|
||||||
|
|
||||||
|
- `runtime_root/state_factory.py:29` returns `tuple[StateEstimator, ISam2GraphHandle]` where the handle is a `ISam2GraphHandleImpl` instance.
|
||||||
|
- `runtime_root/pose_factory.py:118` runtime-checks `isinstance(isam2_graph_handle, c4.ISam2GraphHandle)` and raises `PoseEstimatorConfigError("...does not satisfy the C4 ISam2GraphHandle Protocol (missing get_pose_key?)")` on failure.
|
||||||
|
- `compose_root(config)` (forthcoming) would have wired `state, handle = build_state_estimator(...); pose = build_pose_estimator(..., isam2_graph_handle=handle)` and tripped that gate.
|
||||||
|
|
||||||
|
**Specification deviation**
|
||||||
|
|
||||||
|
The C5 impl was supposed to expose `get_pose_key` as part of its public consumer surface (AZ-355 Risk 1 + the explicit "strictly a superset" claim in the c4 docstring). The C5 estimator already has the canonical name for the same lookup: `GtsamIsam2StateEstimator.key_for_frame(frame_id)` at `c5_state/gtsam_isam2_estimator.py:406`. The handle simply forgot to surface it.
|
||||||
|
|
||||||
|
**Why nothing has tripped yet**
|
||||||
|
|
||||||
|
- No `compose_root(config)` end-to-end wiring exists yet — `runtime_root/__init__.py` has the takeoff-abort and FDR-open paths but does not yet call both factories in one pass.
|
||||||
|
- AZ-358 (C4 OpenCV/GTSAM concrete impl) is in `todo/` — its tests would have been the first to hit the gate.
|
||||||
|
- The two test files (`test_az355_pose_protocol.py`, `test_az381_state_protocol.py`) each import `ISam2GraphHandle` from their *own* component, so each test passes in isolation; no test exercised cross-component conformance until this remediation added one.
|
||||||
|
|
||||||
|
**Applied remediation**
|
||||||
|
|
||||||
|
1. Added `ISam2GraphHandleImpl.get_pose_key(frame_id: int) -> int` (3 lines + docstring) at `components/c5_state/_isam2_handle.py`, delegating to `self._estimator.key_for_frame(frame_id)`. This is the C5-side canonical name for the same `'x'`-namespace lookup; the parameter `int` is a subset of `key_for_frame`'s declared `UUID | int`.
|
||||||
|
2. Added cross-component conformance test `test_handle_satisfies_c4_isam2_graph_handle_protocol` in `tests/unit/c5_state/test_az381_state_protocol.py` that imports the C4 Protocol and asserts both `isinstance(handle, C4ISam2GraphHandle)` and the delegation call to `estimator.key_for_frame`.
|
||||||
|
3. Updated `ISam2GraphHandleImpl` docstring to record the dual-Protocol satisfaction contract.
|
||||||
|
|
||||||
|
The two Protocols **stay as they are**. The c4 stub remains minimal (C4 imports nothing from C5). The c5 Protocol remains the C5-internal surface. The single concrete impl now satisfies both.
|
||||||
|
|
||||||
|
**Task reference**: AZ-355 (C4 stub), AZ-381 (C5 Protocol + impl), pose_factory (AZ-355 Outcome), state_factory (AZ-381 Outcome).
|
||||||
|
|
||||||
|
### F2: 4 `except: pass` sites in production code without justifying comments (Medium / Maintainability)
|
||||||
|
|
||||||
|
**Locations**:
|
||||||
|
|
||||||
|
| File | Line | Context |
|
||||||
|
|----------------------------------------------------------------------------------------|------|-------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `src/gps_denied_onboard/runtime_root/__init__.py` | 545 | Inside `composition_root.takeoff_abort_stop_failed` log path — last-ditch swallow if the logger itself raises |
|
||||||
|
| `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` | 587 | Best-effort STATUSTEXT emission on source-set switch failure (structured log already captured upstream) |
|
||||||
|
| `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py` | 143 | `close()` on an already-broken MSP/MAV connection |
|
||||||
|
| `src/gps_denied_onboard/components/c13_fdr/writer.py` | 541 | Cleanup of an empty segment file after a write failure |
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
`coderule.mdc` requires: "Never suppress errors silently — no `2>/dev/null`, empty `catch` blocks, bare `except: pass`, or discarded error returns. … If an error is truly safe to ignore, log it or comment why." All four sites were defensible (defensive cleanup or best-effort secondary emission, never primary business logic) but none carried a justifying inline comment.
|
||||||
|
|
||||||
|
**Applied remediation**
|
||||||
|
|
||||||
|
Added a justifying multi-line comment at each of the four sites explaining (a) what the suppressed call is doing, (b) where the primary failure path surfaces, and (c) why losing the secondary signal is safe.
|
||||||
|
|
||||||
|
### F3: 7 cumulative-review triggers were skipped before this run (Low / Process)
|
||||||
|
|
||||||
|
**Location**: `_docs/03_implementation/`
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
`implement/SKILL.md` § 14.5 says: "every K completed batches (default K = 3) … invoke `.cursor/skills/code-review/SKILL.md` in cumulative mode. … Output: write the report to `_docs/03_implementation/cumulative_review_batches_[NN-MM]_cycle[N]_report.md`." Across batches 01-22, no `cumulative_review_*` file existed prior to this report. Cumulative review is the mechanism for catching architecture drift, cross-task inconsistency, and contract drift between batches — exactly the class of issue F1 was.
|
||||||
|
|
||||||
|
F1 demonstrates the cost: a single missing method that has lived in the codebase since AZ-381 landed would have been caught on the first cumulative review and resolved with a 1-point fix. By the time it was caught here, it required the same 1-point fix plus a new conformance test plus this writeup.
|
||||||
|
|
||||||
|
**Remediation**
|
||||||
|
|
||||||
|
Going forward, the implement skill's step 14.5 trigger fires at batch 25 (next K=3 boundary after this report). The previously skipped triggers are not replayed — this report consolidates them.
|
||||||
|
|
||||||
|
## Section-by-Phase Notes (cumulative scope)
|
||||||
|
|
||||||
|
### Phase 1 — Context loading
|
||||||
|
Read `_docs/02_document/architecture.md` (ADR-001, ADR-002, ADR-009 noted), `_docs/02_document/module-layout.md` (14 components, 5-layer table), the 44 completed task specs in `_docs/02_tasks/done/`, and the 22 per-batch reports in `_docs/03_implementation/`.
|
||||||
|
|
||||||
|
### Phase 2 — Spec compliance
|
||||||
|
No new findings beyond F1. All 44 completed tasks have batch reports with PASS or PASS_WITH_WARNINGS verdicts; per-batch review verdicts are recorded under `_docs/03_implementation/reviews/`.
|
||||||
|
|
||||||
|
### Phase 3 — Code quality
|
||||||
|
F2 captures the only structural smell. No SOLID violation, no functions over 50 lines without explanation, no obvious DRY violations across the 22 batches. The recent batches (20, 21, 22) introduced helpers (`_validate_takeoff_origin_args`) that the agents themselves flagged as duplication candidates for a future refactor — see batch_22_cycle1_report.md § Self-review findings. Not promoted to a finding here because the agents already surfaced and accepted them.
|
||||||
|
|
||||||
|
### Phase 4 — Security quick-scan
|
||||||
|
No findings. No SQL string interpolation (psycopg parameterized queries throughout c6/c10 work). No `shell=True` in subprocess calls. No hardcoded secrets in source (`.env` patterns absent). MAVLink signing key handling in AZ-395 (batch 17) uses zeroisation per the task spec.
|
||||||
|
|
||||||
|
### Phase 5 — Performance scan
|
||||||
|
No findings. The two performance-sensitive hot paths (`ISam2GraphHandleImpl.add_factor`, `compute_marginals`) carry NFR targets in their task specs and the AZ-382/383/384 batch reports record measured timings within target.
|
||||||
|
|
||||||
|
### Phase 6 — Cross-task consistency
|
||||||
|
F1 is the dominant Phase-6 finding. Other cross-task checks:
|
||||||
|
|
||||||
|
- DTO surface drift across `_types/`: `FlightStateSignal` and `GpsHealth` moved from `_types/nav.py` to `_types/fc.py` as part of AZ-390 (batch 16) — `_types/nav.py:73-77` carries a comment explaining the migration. No stale imports of the old location detected.
|
||||||
|
- `VioOutput` lives in `_types/vio.py` and is referenced by `c1_vio/__init__.py`, `c5_state/*` paths. AZ-331 task spec (todo) says it MUST live in `_types/nav.py` — that constraint is a forward-looking AZ-331 obligation; today's placement is internal to `_types/` and consumers import via `_types.vio` cleanly.
|
||||||
|
- `WgsConverter.horizontal_distance_m` was added by AZ-490 (batch 22) and reuses `latlonalt_to_local_enu`. No duplicate distance helper exists elsewhere.
|
||||||
|
|
||||||
|
### Phase 7 — Architecture compliance
|
||||||
|
1. **Layer direction**: every cross-component import in `src/gps_denied_onboard/` was scanned. All cross-component imports stay within the same component, go through a component's Public API (`__init__.py` re-exports or `interface.py`), or originate from `runtime_root/` (which is allowed to import concrete internals per § 6 of module-layout). No L1 → L2/L3/L4 imports; no L2 → L3/L4 imports.
|
||||||
|
2. **Public API respect**: pose_factory imports `c4_pose._isam2_handle:ISam2GraphHandle` (within-component, fine); state_factory imports `c5_state._isam2_handle:ISam2GraphHandle` (within-component, fine). Neither imports the other's Protocol — the dual-Protocol design preserves component independence. F1 was a conformance gap on the impl side, not a Public-API breach.
|
||||||
|
3. **No new cyclic dependencies**: import graph traced; no cycles introduced across batches 01-22.
|
||||||
|
4. **Same-named Protocols across components**: `ISam2GraphHandle` appears in both `c4_pose` and `c5_state` — this is **intentional dual-Protocol design** per AZ-355 Risk 1 (not duplication). The one concrete impl (`ISam2GraphHandleImpl`) MUST satisfy both Protocols; F1's remediation enforces that contract with a cross-component conformance test.
|
||||||
|
5. **Cross-cutting concerns reimplemented locally**: 14 component files use `time.monotonic_ns` / `time.time_ns` / `time.sleep` directly. This will become a violation when AZ-398 (in `todo/`) lands and declares `Clock` Invariant 2. Today it is **not** a finding because the Clock contract does not yet exist; recorded here so the AZ-398 batch can plan the retrofit scope (~14 files).
|
||||||
|
|
||||||
|
## Files Touched (batches 01-22)
|
||||||
|
|
||||||
|
Computed from `git log --name-only` against the 22 batch commits. Trimmed for brevity; the union covers `src/gps_denied_onboard/` (118 `.py` files) and `tests/unit/` (83 `.py` files) approximately in full, plus selected `_docs/02_document/` updates per AZ-385, AZ-489, AZ-490.
|
||||||
|
|
||||||
|
## Verdict & Gate
|
||||||
|
|
||||||
|
**Post-remediation: PASS.**
|
||||||
|
|
||||||
|
F1 + F2 fixed in this session (see "Applied remediation" sections above). F3 is informational and is addressed by going forward (next trigger at batch 25).
|
||||||
|
|
||||||
|
Files changed in remediation:
|
||||||
|
|
||||||
|
- `src/gps_denied_onboard/components/c5_state/_isam2_handle.py` — added `ISam2GraphHandleImpl.get_pose_key` + updated class docstring
|
||||||
|
- `tests/unit/c5_state/test_az381_state_protocol.py` — added cross-component conformance test
|
||||||
|
- `src/gps_denied_onboard/runtime_root/__init__.py` — comment at line 545
|
||||||
|
- `src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` — comment at line 587
|
||||||
|
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py` — comment at line 143
|
||||||
|
- `src/gps_denied_onboard/components/c13_fdr/writer.py` — comment at line 541
|
||||||
|
|
||||||
|
## Post-mortem — Review framing error (recorded for `meta-rule.mdc` Long Investigation Retrospective)
|
||||||
|
|
||||||
|
The first draft of this report mis-categorised F1 as "duplicate Protocols / Protocol drift" and proposed unifying the two Protocols. That framing was wrong: the dual-Protocol design is intentional (and explicitly documented in the c4 stub's docstring + AZ-355 Risk 1). The bug was narrower — the C5 impl was missing one method needed to satisfy the C4 stub.
|
||||||
|
|
||||||
|
Bottleneck: I treated two same-named Protocols in different components as accidental duplication without first reading each Protocol's docstring rationale. The c4 stub explicitly states "the C5 concrete handle is strictly a superset of this surface" — the docstring rationale would have steered the diagnosis correctly on first read.
|
||||||
|
|
||||||
|
Lesson: when scanning for duplicate symbols across components in Phase 7 #4, read each symbol's docstring rationale before classifying as "duplication". If the docstring explicitly justifies a consumer-side/producer-side split, the finding is NOT duplication — it's a possible conformance gap on the IMPL side (different category, different fix, different size).
|
||||||
@@ -6,9 +6,9 @@ step: 7
|
|||||||
name: Implement
|
name: Implement
|
||||||
status: in_progress
|
status: in_progress
|
||||||
sub_step:
|
sub_step:
|
||||||
phase: 6
|
phase: 14
|
||||||
name: implement-tasks
|
name: cumulative-code-review
|
||||||
detail: "batch 20 of N committed (AZ-355 c4 PoseEstimator Protocol + factory + DTOs + composition: new PoseEstimate shape (UUID + LatLonAlt + Quat + np.ndarray + CovarianceMode + PoseSourceLabel + emitted_at ns) + errors + ISam2GraphHandle stub + build_pose_estimator with lazy-import + C4PoseConfig; C5 consumers migrated in lockstep; legacy raw-4x4 pose_se3 shape retired)"
|
detail: "PASS after F1+F2 remediation in-session; F3 informational; ready for batch 23"
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
tracker: jira
|
tracker: jira
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Jira transition for AZ-355 deferred — MCP "Not connected"
|
|
||||||
|
|
||||||
**Recorded**: 2026-05-11T10:50+03:00 (Europe/Kyiv)
|
|
||||||
**Status**: deferred-non-user (replay on next autodev invocation when Jira MCP is connected)
|
|
||||||
|
|
||||||
## What is blocked
|
|
||||||
|
|
||||||
Status transition of `AZ-355` from `To Do` → `Done` to reflect that
|
|
||||||
Batch 20 has landed the C4 `PoseEstimator` Protocol + factory + DTOs
|
|
||||||
+ composition wiring (see
|
|
||||||
`_docs/03_implementation/batch_20_cycle1_report.md`).
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The Atlassian MCP server returned `"Not connected"` on
|
|
||||||
`getAccessibleAtlassianResources` during the Batch 20 wrap-up — same
|
|
||||||
failure mode that AZ-386 hit a few hours earlier. Per the
|
|
||||||
Leftovers Mechanism the write is recorded here and the non-tracker
|
|
||||||
work (commit, push) is allowed to proceed; the next autodev
|
|
||||||
invocation will replay the transition.
|
|
||||||
|
|
||||||
## Replay payload
|
|
||||||
|
|
||||||
- **Tool**: `transitionJiraIssue`
|
|
||||||
- **cloudId**: `denyspopov.atlassian.net`
|
|
||||||
- **issueIdOrKey**: `AZ-355`
|
|
||||||
- **target status**: `Done` (transition id is project-specific; resolve
|
|
||||||
via `getTransitionsForJiraIssue` at replay time — Jira project `AZ`
|
|
||||||
uses the standard "Software" workflow so the transition is `id: 31`
|
|
||||||
in current Jira config; confirm at replay time).
|
|
||||||
|
|
||||||
## Acceptance check on replay
|
|
||||||
|
|
||||||
After the transition succeeds:
|
|
||||||
|
|
||||||
- `getJiraIssue(AZ-355)` returns `fields.status.name == "Done"`.
|
|
||||||
- Delete this leftover file.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Code, tests, docs, and state file are all updated and committed. The
|
|
||||||
only outstanding action is the tracker status transition; the
|
|
||||||
AZ-355 task spec is already in `_docs/02_tasks/done/`.
|
|
||||||
- The previous leftover for AZ-386 is still pending; replay both in
|
|
||||||
one batch when the MCP comes back.
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Jira transition for AZ-386 deferred — MCP "Not connected"
|
|
||||||
|
|
||||||
**Recorded**: 2026-05-11T10:00+03:00 (Europe/Kyiv)
|
|
||||||
**Status**: deferred-non-user (replay on next autodev invocation when Jira MCP is connected)
|
|
||||||
|
|
||||||
## What is blocked
|
|
||||||
|
|
||||||
Status transition of `AZ-386` from `To Do` → `Done` to reflect that
|
|
||||||
Batch 19 has landed the `EskfStateEstimator` mandatory simple-baseline
|
|
||||||
in code + tests + documentation (see
|
|
||||||
`_docs/03_implementation/batch_19_cycle1_report.md`).
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The Atlassian MCP server returned `"Not connected"` on both
|
|
||||||
`transitionJiraIssue` and `getAccessibleAtlassianResources` during the
|
|
||||||
Batch 19 wrap-up. This is the documented "MCP unavailable" failure
|
|
||||||
mode in `.cursor/rules/tracker.mdc`. Per the Leftovers Mechanism the
|
|
||||||
write is recorded here and the non-tracker work (commit, push) is
|
|
||||||
allowed to proceed; the next autodev invocation will replay the
|
|
||||||
transition.
|
|
||||||
|
|
||||||
## Replay payload
|
|
||||||
|
|
||||||
- **Tool**: `transitionJiraIssue`
|
|
||||||
- **cloudId**: `denyspopov.atlassian.net`
|
|
||||||
- **issueIdOrKey**: `AZ-386`
|
|
||||||
- **target status**: `Done` (transition id is project-specific; resolve
|
|
||||||
via `getTransitionsForJiraIssue` at replay time — Jira project `AZ`
|
|
||||||
uses the standard "Software" workflow so the transition is `id: 31`
|
|
||||||
in current Jira config; confirm at replay time).
|
|
||||||
|
|
||||||
## Acceptance check on replay
|
|
||||||
|
|
||||||
After the transition succeeds:
|
|
||||||
|
|
||||||
- `getJiraIssue(AZ-386)` returns `fields.status.name == "Done"`.
|
|
||||||
- Delete this leftover file.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Code, tests, docs, and state file are all updated and committed. The
|
|
||||||
only outstanding action is the tracker status transition; the
|
|
||||||
AZ-386 task spec is already in `_docs/02_tasks/done/`.
|
|
||||||
@@ -42,6 +42,10 @@ dependencies = [
|
|||||||
"pyproj>=3.6,<4.0",
|
"pyproj>=3.6,<4.0",
|
||||||
# FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272).
|
# FDR wire format for fdr_client.records (E-CC-FDR-CLIENT / AZ-272).
|
||||||
"orjson>=3.9,<4.0",
|
"orjson>=3.9,<4.0",
|
||||||
|
# HTTPS client for C12 FlightsApiClient (AZ-489 / ADR-010). Picked over
|
||||||
|
# `requests` because httpx ships `MockTransport` natively, so the
|
||||||
|
# FlightsApi unit tests need no extra HTTP-mocking dep.
|
||||||
|
"httpx>=0.28,<1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ __all__ = [
|
|||||||
"FlightState",
|
"FlightState",
|
||||||
"FlightStateSignal",
|
"FlightStateSignal",
|
||||||
"GpsHealth",
|
"GpsHealth",
|
||||||
|
"GpsSample",
|
||||||
"GpsStatus",
|
"GpsStatus",
|
||||||
"ImuTelemetrySample",
|
"ImuTelemetrySample",
|
||||||
"OperatorCommand",
|
"OperatorCommand",
|
||||||
@@ -142,6 +143,22 @@ class GpsHealth:
|
|||||||
captured_at: int
|
captured_at: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GpsSample:
|
||||||
|
"""Single FC-reported GPS lat/lon/alt sample (post-decode form).
|
||||||
|
|
||||||
|
Distinct from :class:`GpsHealth` (which carries only the health
|
||||||
|
status enum) — this is the geographic position the FC reports
|
||||||
|
alongside the health bucket. Consumed by C5's bounded-delta gate
|
||||||
|
(AZ-490 / Principle #11 third clause) as the `vincenty(sample,
|
||||||
|
smoother)` input. ``captured_at`` is monotonic_ns at the decode
|
||||||
|
boundary, mirroring :class:`GpsHealth` for Invariant 7.
|
||||||
|
"""
|
||||||
|
|
||||||
|
position_wgs84: LatLonAlt
|
||||||
|
captured_at: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FlightStateSignal:
|
class FlightStateSignal:
|
||||||
"""FC's high-level flight-state lattice + AC-5.1 warm-start hint."""
|
"""FC's high-level flight-state lattice + AC-5.1 warm-start hint."""
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""C7 inference runtime DTOs + enums (AZ-297) — L1 shared types.
|
||||||
|
|
||||||
|
Hosted at the ``_types`` layer (not under ``components/c7_inference``)
|
||||||
|
because C10 ``CacheProvisioner`` re-exports :class:`EngineCacheEntry`
|
||||||
|
as part of its own Public API; per the architecture rule
|
||||||
|
(``test_az270_compose_root.py`` AC-6) components MUST NOT import
|
||||||
|
other components, so cross-component DTOs live in ``_types/``.
|
||||||
|
|
||||||
|
C7's :class:`gps_denied_onboard.components.c7_inference` package
|
||||||
|
re-exports these names for the canonical contract surface; consumers
|
||||||
|
SHOULD import from ``c7_inference`` (the contract namespace) rather
|
||||||
|
than from here. The contract at
|
||||||
|
``_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md``
|
||||||
|
v1.0.0 is the authoritative shape; this module mirrors it 1:1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BuildConfig",
|
||||||
|
"EngineCacheEntry",
|
||||||
|
"EngineHandle",
|
||||||
|
"OptimizationProfile",
|
||||||
|
"PrecisionMode",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PrecisionMode(str, Enum):
|
||||||
|
"""TensorRT / ORT / PyTorch precision the engine is compiled for."""
|
||||||
|
|
||||||
|
FP16 = "fp16"
|
||||||
|
INT8 = "int8"
|
||||||
|
MIXED = "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OptimizationProfile:
|
||||||
|
"""One named TRT optimisation profile.
|
||||||
|
|
||||||
|
The runtime picks an actual shape inside ``[min_shape, max_shape]``
|
||||||
|
based on the input dict supplied to ``infer``; the Protocol does
|
||||||
|
NOT auto-batch (see ``inference_runtime_protocol.md`` Non-Goals).
|
||||||
|
"""
|
||||||
|
|
||||||
|
input_name: str
|
||||||
|
min_shape: tuple[int, ...]
|
||||||
|
opt_shape: tuple[int, ...]
|
||||||
|
max_shape: tuple[int, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BuildConfig:
|
||||||
|
"""Engine-build hyperparameters passed to ``compile_engine``.
|
||||||
|
|
||||||
|
``calibration_dataset`` is required when ``precision == INT8`` and
|
||||||
|
is ``None`` for FP16 / MIXED. ``use_trtexec`` is a TRT-only hint;
|
||||||
|
the ORT / PyTorch strategies ignore it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
precision: PrecisionMode
|
||||||
|
workspace_mb: int
|
||||||
|
calibration_dataset: Path | None
|
||||||
|
optimization_profiles: tuple[OptimizationProfile, ...]
|
||||||
|
use_trtexec: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EngineCacheEntry:
|
||||||
|
"""Self-describing pointer to a compiled engine on disk.
|
||||||
|
|
||||||
|
The five-tuple ``(model, sm, jp, trt, precision)`` matches the
|
||||||
|
filename schema from AZ-281 (``engine_filename_schema.md``);
|
||||||
|
``sha256_hex`` is produced via the AZ-280 sidecar pattern. For
|
||||||
|
PyTorch baselines the ``sm`` / ``jp`` / ``trt`` fields are ``None``
|
||||||
|
(the runtime is hardware-portable). ``extras`` carries
|
||||||
|
strategy-specific metadata (e.g., calibration cache path).
|
||||||
|
"""
|
||||||
|
|
||||||
|
engine_path: Path
|
||||||
|
sha256_hex: str
|
||||||
|
sm: int | None
|
||||||
|
jp: str | None
|
||||||
|
trt: str | None
|
||||||
|
precision: PrecisionMode
|
||||||
|
extras: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class EngineHandle:
|
||||||
|
"""Opaque token returned by C7 ``deserialize_engine``.
|
||||||
|
|
||||||
|
Consumers MUST pass the handle back to the same runtime
|
||||||
|
(``infer`` / ``release_engine``) and MUST NOT introspect fields —
|
||||||
|
Invariant I-4 of ``inference_runtime_protocol.md``. Each concrete
|
||||||
|
strategy subclasses this with implementation-private state.
|
||||||
|
|
||||||
|
A separate :class:`gps_denied_onboard._types.manifests.EngineHandle`
|
||||||
|
Protocol exists for the LightGlue consumer-side cut (descriptor_dim
|
||||||
|
+ forward). That is a duck-typed structural cut, intentionally
|
||||||
|
distinct from this opaque marker; concrete C7 strategies subclass
|
||||||
|
THIS class and structurally satisfy the LightGlue Protocol when
|
||||||
|
paired with a matching engine binary.
|
||||||
|
"""
|
||||||
@@ -22,19 +22,6 @@ class Manifest:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class EngineCacheEntry:
|
|
||||||
"""TensorRT engine + calibration cache, keyed by SM/JP/TRT/precision (D-C10-7)."""
|
|
||||||
|
|
||||||
engine_path: str
|
|
||||||
sm_arch: str
|
|
||||||
jetpack_version: str
|
|
||||||
tensorrt_version: str
|
|
||||||
precision: str
|
|
||||||
content_hash: str
|
|
||||||
int8_calibration_path: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EngineCacheKey:
|
class EngineCacheKey:
|
||||||
"""Parsed tuple of a self-describing `.engine` filename (D-C10-7, AZ-281).
|
"""Parsed tuple of a self-describing `.engine` filename (D-C10-7, AZ-281).
|
||||||
@@ -53,12 +40,23 @@ class EngineCacheKey:
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class EngineHandle(Protocol):
|
class EngineHandle(Protocol):
|
||||||
"""Opaque Protocol for an inference engine handle (C7-owned implementation).
|
"""Consumer-side structural cut of a C7 engine handle for the
|
||||||
|
LightGlue helper (AZ-278 / R14 fix).
|
||||||
|
|
||||||
The production handle is created by C7's
|
Intentionally distinct from
|
||||||
``InferenceRuntime.deserialize_engine`` and injected by the
|
:class:`gps_denied_onboard.components.c7_inference.EngineHandle`
|
||||||
composition root into ``LightGlueRuntime``. The helper depends on
|
(the AZ-297 opaque marker class): same name, different shape, by
|
||||||
this Protocol from `_types` so Layer 1 never imports C7 (R14 fix).
|
design. The C7 Protocol says EngineHandle is opaque to general
|
||||||
|
consumers (Invariant I-4); LightGlueRuntime is a specific helper
|
||||||
|
that needs a narrower structural Protocol with
|
||||||
|
``descriptor_dim`` + ``forward``. Concrete C7 strategies
|
||||||
|
(TensorrtRuntime / OnnxTrtEpRuntime / PytorchFp16Runtime)
|
||||||
|
subclass the c7_inference opaque marker AND structurally satisfy
|
||||||
|
this Protocol when paired with a matching engine binary.
|
||||||
|
|
||||||
|
The helper depends on THIS Protocol from ``_types`` so Layer 1
|
||||||
|
never imports C7 (R14 fix). See ADR for the dual-Protocol
|
||||||
|
design rationale (mirrors the C4/C5 ISam2GraphHandle split).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class CovarianceMode(Enum):
|
|||||||
iSAM2 graph (ADR-003 substrate). The cost-accurate path.
|
iSAM2 graph (ADR-003 substrate). The cost-accurate path.
|
||||||
* ``JACOBIAN`` — D-CROSS-LATENCY-1 thermal-throttle fallback
|
* ``JACOBIAN`` — D-CROSS-LATENCY-1 thermal-throttle fallback
|
||||||
(ADR-006). Cheaper but ~5-10% less accurate; engaged per-frame
|
(ADR-006). Cheaper but ~5-10% less accurate; engaged per-frame
|
||||||
when ``ThermalState.throttle == True``.
|
when ``ThermalState.thermal_throttle_active == True``.
|
||||||
|
|
||||||
AZ-355 owns the enum; AZ-358 (Marginals) + AZ-361 (Hybrid) own
|
AZ-355 owns the enum; AZ-358 (Marginals) + AZ-361 (Hybrid) own
|
||||||
the producer paths.
|
the producer paths.
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"""C7 ``ThermalState`` DTO stub (forward-declared for AZ-355).
|
"""C7 ``ThermalState`` DTO (frozen at AZ-297 v1.0.0).
|
||||||
|
|
||||||
AZ-355 (C4 PoseEstimator Protocol) needs a ``ThermalState`` type to
|
Originally introduced as a forward-declared stub for AZ-355 (C4 needs
|
||||||
annotate :meth:`PoseEstimator.estimate`. The full producer
|
the type before C7 lands); AZ-297 expanded the surface to the full
|
||||||
(``ThermalStatePublisher`` in C7) is owned by AZ-302; this module
|
contract shape (cpu/gpu temp, throttle, measured clock, monotonic
|
||||||
holds the minimal DTO surface C4 needs so the Protocol typechecks
|
timestamp, telemetry-availability bit) and AZ-302 will own the
|
||||||
without a circular dependency or a ``TYPE_CHECKING`` workaround.
|
producer-side polling thread.
|
||||||
|
|
||||||
When AZ-302 lands, it MAY add fields here (temperature reading,
|
C4 consumers read ``thermal_throttle_active`` (the canonical name);
|
||||||
thermal-zone source, captured_at) but MUST keep the ``throttle``
|
the older docstring references to ``throttle`` are stale and now
|
||||||
boolean — it is the only field the C4 Protocol contract pins.
|
point at this field. Invariant I-6 of
|
||||||
|
``_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md``
|
||||||
|
binds ``thermal_throttle_active == False`` whenever
|
||||||
|
``is_telemetry_available == False``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,11 +25,22 @@ __all__ = ["ThermalState"]
|
|||||||
class ThermalState:
|
class ThermalState:
|
||||||
"""C7-reported thermal state consumed by C4 for the per-frame mode switch.
|
"""C7-reported thermal state consumed by C4 for the per-frame mode switch.
|
||||||
|
|
||||||
``throttle == True`` triggers the Jacobian path (D-CROSS-LATENCY-1
|
``thermal_throttle_active == True`` triggers the Jacobian path
|
||||||
/ ADR-006). ``False`` keeps the production Marginals path.
|
(D-CROSS-LATENCY-1 / ADR-006); ``False`` keeps the production
|
||||||
|
Marginals path. ``is_telemetry_available`` is the default-safe
|
||||||
|
bit: when the source is hung or absent, consumers see
|
||||||
|
``thermal_throttle_active == False`` regardless of any other
|
||||||
|
field's value.
|
||||||
|
|
||||||
The full C7 publisher (AZ-302) emits these on a fixed cadence;
|
Temperatures are degrees Celsius; ``measured_clock_mhz`` is the
|
||||||
C4 reads the latest value at every ``estimate`` call entry.
|
most-recent GPU clock report (None when unavailable);
|
||||||
|
``measured_at_ns`` is :func:`time.monotonic_ns` at poll time so
|
||||||
|
consumers can age-gate the reading.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
throttle: bool
|
cpu_temp_c: float | None
|
||||||
|
gpu_temp_c: float | None
|
||||||
|
thermal_throttle_active: bool
|
||||||
|
measured_clock_mhz: int | None
|
||||||
|
measured_at_ns: int
|
||||||
|
is_telemetry_available: bool
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
"""C6 tile-cache DTOs."""
|
"""Legacy C6 tile-cache scaffolding DTOs.
|
||||||
|
|
||||||
|
AZ-303 froze the canonical C6 contract; ``TileQualityMetadata`` and
|
||||||
|
``SectorClassification`` (as a dataclass) moved into
|
||||||
|
``gps_denied_onboard.components.c6_tile_cache._types`` (the former
|
||||||
|
matches 1:1; the latter became a ``str, Enum``).
|
||||||
|
|
||||||
|
The two remaining types here (``Tile``, ``TileRecord``) are still
|
||||||
|
referenced by the C3 / C11 scaffolding Protocols
|
||||||
|
(``components/c3_matcher/interface.py``,
|
||||||
|
``components/c11_tile_manager/interface.py``). Their own component
|
||||||
|
tasks (AZ-344 for C3, AZ-316 / AZ-319 for C11) will replace those
|
||||||
|
Protocol stubs with the AZ-303 DTOs; this file disappears in the
|
||||||
|
same migration step.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,7 +23,11 @@ from typing import Any
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Tile:
|
class Tile:
|
||||||
"""A single satellite tile (image body + metadata)."""
|
"""Scaffolding satellite-tile DTO used by the C3 matcher Protocol stub.
|
||||||
|
|
||||||
|
Superseded by :class:`gps_denied_onboard.components.c6_tile_cache.TileMetadata`;
|
||||||
|
held here only until AZ-344 retires the C3 scaffolding.
|
||||||
|
"""
|
||||||
|
|
||||||
tile_id: str
|
tile_id: str
|
||||||
zoom_level: int
|
zoom_level: int
|
||||||
@@ -20,20 +38,13 @@ class Tile:
|
|||||||
image_path: str
|
image_path: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TileQualityMetadata:
|
|
||||||
"""Quality metadata attached to an onboard-ingested tile (D-PROJ-2 ingest contract)."""
|
|
||||||
|
|
||||||
estimator_label: str
|
|
||||||
covariance_2x2: tuple[tuple[float, float], tuple[float, float]]
|
|
||||||
last_anchor_age_ms: int
|
|
||||||
mre_px: float
|
|
||||||
imu_bias_norm: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TileRecord:
|
class TileRecord:
|
||||||
"""Postgres row for a tile (mirrors satellite-provider's canonical columns + additive)."""
|
"""Scaffolding Postgres row used by the C11 manager Protocol stub.
|
||||||
|
|
||||||
|
Superseded by :class:`gps_denied_onboard.components.c6_tile_cache.TileMetadata`;
|
||||||
|
held here only until AZ-316 / AZ-319 retire the C11 scaffolding.
|
||||||
|
"""
|
||||||
|
|
||||||
tile_id: str
|
tile_id: str
|
||||||
zoom_level: int
|
zoom_level: int
|
||||||
@@ -46,14 +57,4 @@ class TileRecord:
|
|||||||
flight_id: str | None = None
|
flight_id: str | None = None
|
||||||
companion_id: str | None = None
|
companion_id: str | None = None
|
||||||
capture_timestamp: datetime | None = None
|
capture_timestamp: datetime | None = None
|
||||||
quality: TileQualityMetadata | None = None
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SectorClassification:
|
|
||||||
"""Operator-set classification of a geographic sector (urban / forest / agriculture / …)."""
|
|
||||||
|
|
||||||
sector_id: str
|
|
||||||
classification: str
|
|
||||||
freshness_threshold_days: int
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"""C10 Cache Provisioning component — Public API."""
|
"""C10 Cache Provisioning component — Public API.
|
||||||
|
|
||||||
from gps_denied_onboard._types.manifests import EngineCacheEntry, Manifest
|
``EngineCacheEntry`` is the C7 canonical DTO (frozen at AZ-297) and
|
||||||
|
lives at the L1 ``_types`` layer so C10 can re-export it without
|
||||||
|
crossing the components.* boundary (architecture rule AC-6).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.inference import EngineCacheEntry
|
||||||
|
from gps_denied_onboard._types.manifests import Manifest
|
||||||
from gps_denied_onboard.components.c10_provisioning.interface import CacheProvisioner
|
from gps_denied_onboard.components.c10_provisioning.interface import CacheProvisioner
|
||||||
|
|
||||||
__all__ = ["CacheProvisioner", "EngineCacheEntry", "Manifest"]
|
__all__ = ["CacheProvisioner", "EngineCacheEntry", "Manifest"]
|
||||||
|
|||||||
@@ -1,8 +1,47 @@
|
|||||||
"""C12 Operator Pre-flight Tooling component — Public API."""
|
"""C12 Operator Pre-flight Tooling component — Public API."""
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||||
|
EmptyWaypointsError,
|
||||||
|
FlightDto,
|
||||||
|
FlightFileNotFoundError,
|
||||||
|
FlightNotFoundError,
|
||||||
|
FlightsApiAuthError,
|
||||||
|
FlightsApiClient,
|
||||||
|
FlightsApiError,
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
FlightsApiUnreachableError,
|
||||||
|
HttpxFlightsApiClient,
|
||||||
|
WaypointDto,
|
||||||
|
WaypointObjective,
|
||||||
|
WaypointSchemaError,
|
||||||
|
WaypointSource,
|
||||||
|
bbox_from_waypoints,
|
||||||
|
load_flight_file,
|
||||||
|
takeoff_origin_from_flight,
|
||||||
|
)
|
||||||
from gps_denied_onboard.components.c12_operator_tooling.interface import (
|
from gps_denied_onboard.components.c12_operator_tooling.interface import (
|
||||||
CacheBuildWorkflow,
|
CacheBuildWorkflow,
|
||||||
OperatorReLocService,
|
OperatorReLocService,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["CacheBuildWorkflow", "OperatorReLocService"]
|
__all__ = [
|
||||||
|
"CacheBuildWorkflow",
|
||||||
|
"EmptyWaypointsError",
|
||||||
|
"FlightDto",
|
||||||
|
"FlightFileNotFoundError",
|
||||||
|
"FlightNotFoundError",
|
||||||
|
"FlightsApiAuthError",
|
||||||
|
"FlightsApiClient",
|
||||||
|
"FlightsApiError",
|
||||||
|
"FlightsApiSchemaError",
|
||||||
|
"FlightsApiUnreachableError",
|
||||||
|
"HttpxFlightsApiClient",
|
||||||
|
"OperatorReLocService",
|
||||||
|
"WaypointDto",
|
||||||
|
"WaypointObjective",
|
||||||
|
"WaypointSchemaError",
|
||||||
|
"WaypointSource",
|
||||||
|
"bbox_from_waypoints",
|
||||||
|
"load_flight_file",
|
||||||
|
"takeoff_origin_from_flight",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""C12 FlightsApiClient (AZ-489 / ADR-010).
|
||||||
|
|
||||||
|
Read-only resolver that maps an operator-planned mission to the inputs C12
|
||||||
|
needs for the build-cache workflow:
|
||||||
|
|
||||||
|
* :class:`FlightDto` carries the parent-suite ``Flight`` (ordered waypoints
|
||||||
|
+ altitudes) used to derive the cache bbox and the takeoff origin.
|
||||||
|
* :func:`bbox_from_waypoints` envelopes the lat/lon and inflates by a
|
||||||
|
horizontal-distance buffer (NOT a degree-space buffer) via
|
||||||
|
:class:`~gps_denied_onboard.helpers.wgs_converter.WgsConverter`.
|
||||||
|
* :func:`takeoff_origin_from_flight` returns ``waypoints[0]`` as a
|
||||||
|
:class:`~gps_denied_onboard._types.geo.LatLonAlt`.
|
||||||
|
|
||||||
|
Two sources produce the same DTO shape:
|
||||||
|
|
||||||
|
* :meth:`FlightsApiClient.fetch_flight` — HTTPS against the parent-suite
|
||||||
|
``flights`` REST service (online path).
|
||||||
|
* :meth:`FlightsApiClient.load_flight_file` — JSON on disk (offline path).
|
||||||
|
|
||||||
|
Public surface is frozen by
|
||||||
|
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||||
|
v1.0.0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||||
|
bbox_from_waypoints,
|
||||||
|
takeoff_origin_from_flight,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||||
|
EmptyWaypointsError,
|
||||||
|
FlightFileNotFoundError,
|
||||||
|
FlightNotFoundError,
|
||||||
|
FlightsApiAuthError,
|
||||||
|
FlightsApiError,
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
FlightsApiUnreachableError,
|
||||||
|
WaypointSchemaError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||||
|
load_flight_file,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
|
||||||
|
HttpxFlightsApiClient,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||||
|
FlightDto,
|
||||||
|
FlightsApiClient,
|
||||||
|
WaypointDto,
|
||||||
|
WaypointObjective,
|
||||||
|
WaypointSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EmptyWaypointsError",
|
||||||
|
"FlightDto",
|
||||||
|
"FlightFileNotFoundError",
|
||||||
|
"FlightNotFoundError",
|
||||||
|
"FlightsApiAuthError",
|
||||||
|
"FlightsApiClient",
|
||||||
|
"FlightsApiError",
|
||||||
|
"FlightsApiSchemaError",
|
||||||
|
"FlightsApiUnreachableError",
|
||||||
|
"HttpxFlightsApiClient",
|
||||||
|
"WaypointDto",
|
||||||
|
"WaypointObjective",
|
||||||
|
"WaypointSchemaError",
|
||||||
|
"WaypointSource",
|
||||||
|
"bbox_from_waypoints",
|
||||||
|
"load_flight_file",
|
||||||
|
"takeoff_origin_from_flight",
|
||||||
|
]
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Shared JSON-payload → :class:`FlightDto` parser (AZ-489).
|
||||||
|
|
||||||
|
Used by both the online HTTPS client and the offline file loader so they
|
||||||
|
satisfy FAC-INV-1 (same shape, same validation, same error types).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
WaypointSchemaError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||||
|
FlightDto,
|
||||||
|
WaypointDto,
|
||||||
|
WaypointObjective,
|
||||||
|
WaypointSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["parse_flight_payload"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_flight_payload(payload: Any, *, source_label: str) -> FlightDto:
|
||||||
|
"""Validate + normalise ``payload`` into a :class:`FlightDto`.
|
||||||
|
|
||||||
|
``source_label`` is folded into error messages so the operator can tell
|
||||||
|
online failures (``"flights service"``) from offline failures
|
||||||
|
(``"flight file <path>"``) without inspecting the exception type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FlightsApiSchemaError: top-level shape violation.
|
||||||
|
WaypointSchemaError: any single waypoint is malformed, or the
|
||||||
|
ordinal sequence is not a contiguous ``0..N-1`` run.
|
||||||
|
"""
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"{source_label}: expected JSON object at top level; got {type(payload).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
flight_id = _require_uuid(payload, "flight_id", source_label)
|
||||||
|
name = _require_str(payload, "name", source_label)
|
||||||
|
waypoints_raw = _require_list(payload, "waypoints", source_label)
|
||||||
|
|
||||||
|
waypoints = tuple(
|
||||||
|
sorted(
|
||||||
|
(_parse_waypoint(item, index, source_label) for index, item in enumerate(waypoints_raw)),
|
||||||
|
key=lambda wp: wp.ordinal,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_enforce_contiguous_ordinals(waypoints, source_label)
|
||||||
|
return FlightDto(flight_id=flight_id, name=name, waypoints=waypoints)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_waypoint(item: Any, source_index: int, source_label: str) -> WaypointDto:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: waypoint #{source_index} is not a JSON object; "
|
||||||
|
f"got {type(item).__name__}"
|
||||||
|
)
|
||||||
|
ordinal = _require_int(item, "ordinal", f"{source_label} waypoint #{source_index}")
|
||||||
|
if ordinal < 0:
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: waypoint #{source_index} ordinal={ordinal} must be >= 0"
|
||||||
|
)
|
||||||
|
lat_deg = _require_finite_float(item, "lat_deg", f"{source_label} waypoint #{source_index}")
|
||||||
|
if not -90.0 <= lat_deg <= 90.0:
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: waypoint #{source_index} lat_deg={lat_deg} outside [-90, 90]"
|
||||||
|
)
|
||||||
|
lon_deg = _require_finite_float(item, "lon_deg", f"{source_label} waypoint #{source_index}")
|
||||||
|
if not -180.0 <= lon_deg <= 180.0:
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: waypoint #{source_index} lon_deg={lon_deg} outside [-180, 180]"
|
||||||
|
)
|
||||||
|
alt_m = _require_finite_float(item, "alt_m", f"{source_label} waypoint #{source_index}")
|
||||||
|
objective = _parse_enum(
|
||||||
|
item, "objective", WaypointObjective, f"{source_label} waypoint #{source_index}"
|
||||||
|
)
|
||||||
|
source = _parse_enum(
|
||||||
|
item, "source", WaypointSource, f"{source_label} waypoint #{source_index}"
|
||||||
|
)
|
||||||
|
return WaypointDto(
|
||||||
|
ordinal=ordinal,
|
||||||
|
lat_deg=lat_deg,
|
||||||
|
lon_deg=lon_deg,
|
||||||
|
alt_m=alt_m,
|
||||||
|
objective=objective,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_contiguous_ordinals(
|
||||||
|
waypoints: tuple[WaypointDto, ...], source_label: str
|
||||||
|
) -> None:
|
||||||
|
for expected, wp in enumerate(waypoints):
|
||||||
|
if wp.ordinal != expected:
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: waypoint ordinal sequence is not contiguous; "
|
||||||
|
f"expected {expected} at position {expected}, got {wp.ordinal}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_uuid(payload: dict[str, Any], field: str, source_label: str) -> UUID:
|
||||||
|
raw = _require_str(payload, field, source_label)
|
||||||
|
try:
|
||||||
|
return UUID(raw)
|
||||||
|
except (ValueError, AttributeError, TypeError) as exc:
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"{source_label}: field {field!r} is not a valid UUID: {raw!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _require_str(payload: dict[str, Any], field: str, source_label: str) -> str:
|
||||||
|
if field not in payload:
|
||||||
|
raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}")
|
||||||
|
value = payload[field]
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"{source_label}: field {field!r} must be a non-empty string; "
|
||||||
|
f"got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_list(payload: dict[str, Any], field: str, source_label: str) -> list[Any]:
|
||||||
|
if field not in payload:
|
||||||
|
raise FlightsApiSchemaError(f"{source_label}: missing required field {field!r}")
|
||||||
|
value = payload[field]
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"{source_label}: field {field!r} must be a JSON array; got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_int(payload: dict[str, Any], field: str, source_label: str) -> int:
|
||||||
|
if field not in payload:
|
||||||
|
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||||
|
value = payload[field]
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: field {field!r} must be an integer; "
|
||||||
|
f"got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _require_finite_float(payload: dict[str, Any], field: str, source_label: str) -> float:
|
||||||
|
if field not in payload:
|
||||||
|
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||||
|
value = payload[field]
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: field {field!r} must be a number; got {type(value).__name__}"
|
||||||
|
)
|
||||||
|
fvalue = float(value)
|
||||||
|
if not math.isfinite(fvalue):
|
||||||
|
raise WaypointSchemaError(f"{source_label}: field {field!r} must be finite; got {value}")
|
||||||
|
return fvalue
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_enum(
|
||||||
|
payload: dict[str, Any], field: str, enum_cls: type, source_label: str
|
||||||
|
) -> Any:
|
||||||
|
if field not in payload:
|
||||||
|
raise WaypointSchemaError(f"{source_label}: missing required field {field!r}")
|
||||||
|
raw = payload[field]
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: field {field!r} must be a string; got {type(raw).__name__}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return enum_cls(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
valid = sorted(member.value for member in enum_cls)
|
||||||
|
raise WaypointSchemaError(
|
||||||
|
f"{source_label}: field {field!r}={raw!r} is not in {valid}"
|
||||||
|
) from exc
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Bbox + takeoff-origin helpers (AZ-489).
|
||||||
|
|
||||||
|
Implements FAC-INV-3 (horizontal-distance buffer via ENU round-trip) and
|
||||||
|
FAC-INV-4 (takeoff origin is ``waypoints[0]``, no rounding).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||||
|
EmptyWaypointsError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||||
|
FlightDto,
|
||||||
|
WaypointDto,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
|
|
||||||
|
__all__ = ["bbox_from_waypoints", "takeoff_origin_from_flight"]
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_from_waypoints(
|
||||||
|
waypoints: tuple[WaypointDto, ...],
|
||||||
|
*,
|
||||||
|
buffer_m: float = 1000.0,
|
||||||
|
) -> BoundingBox:
|
||||||
|
"""Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres.
|
||||||
|
|
||||||
|
The buffer is applied in local-ENU around the envelope centre so the
|
||||||
|
inflation is a true horizontal distance at the flight's latitude. Naive
|
||||||
|
``min_lat - buffer / 111000`` style buffering is intentionally NOT used
|
||||||
|
— it under-inflates the east/west extent at high latitudes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmptyWaypointsError: ``waypoints`` is empty.
|
||||||
|
ValueError: ``buffer_m`` is negative or non-finite.
|
||||||
|
"""
|
||||||
|
if not waypoints:
|
||||||
|
raise EmptyWaypointsError(
|
||||||
|
"FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI"
|
||||||
|
)
|
||||||
|
if not math.isfinite(buffer_m) or buffer_m < 0.0:
|
||||||
|
raise ValueError(f"buffer_m must be a non-negative finite number; got {buffer_m!r}")
|
||||||
|
|
||||||
|
min_lat = min(wp.lat_deg for wp in waypoints)
|
||||||
|
max_lat = max(wp.lat_deg for wp in waypoints)
|
||||||
|
min_lon = min(wp.lon_deg for wp in waypoints)
|
||||||
|
max_lon = max(wp.lon_deg for wp in waypoints)
|
||||||
|
|
||||||
|
origin = LatLonAlt(
|
||||||
|
lat_deg=(min_lat + max_lat) / 2.0,
|
||||||
|
lon_deg=(min_lon + max_lon) / 2.0,
|
||||||
|
alt_m=0.0,
|
||||||
|
)
|
||||||
|
sw = LatLonAlt(lat_deg=min_lat, lon_deg=min_lon, alt_m=0.0)
|
||||||
|
ne = LatLonAlt(lat_deg=max_lat, lon_deg=max_lon, alt_m=0.0)
|
||||||
|
|
||||||
|
sw_enu = WgsConverter.latlonalt_to_local_enu(origin, sw)
|
||||||
|
ne_enu = WgsConverter.latlonalt_to_local_enu(origin, ne)
|
||||||
|
|
||||||
|
sw_inflated_enu = np.array(
|
||||||
|
[sw_enu[0] - buffer_m, sw_enu[1] - buffer_m, 0.0], dtype=np.float64
|
||||||
|
)
|
||||||
|
ne_inflated_enu = np.array(
|
||||||
|
[ne_enu[0] + buffer_m, ne_enu[1] + buffer_m, 0.0], dtype=np.float64
|
||||||
|
)
|
||||||
|
|
||||||
|
sw_inflated = WgsConverter.local_enu_to_latlonalt(origin, sw_inflated_enu)
|
||||||
|
ne_inflated = WgsConverter.local_enu_to_latlonalt(origin, ne_inflated_enu)
|
||||||
|
|
||||||
|
return BoundingBox(
|
||||||
|
min_lat_deg=sw_inflated.lat_deg,
|
||||||
|
min_lon_deg=sw_inflated.lon_deg,
|
||||||
|
max_lat_deg=ne_inflated.lat_deg,
|
||||||
|
max_lon_deg=ne_inflated.lon_deg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def takeoff_origin_from_flight(flight: FlightDto) -> LatLonAlt:
|
||||||
|
"""Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding, no projection.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmptyWaypointsError: ``flight.waypoints`` is empty (should not happen
|
||||||
|
on a parser-validated DTO; defensive check).
|
||||||
|
"""
|
||||||
|
if not flight.waypoints:
|
||||||
|
raise EmptyWaypointsError(
|
||||||
|
"FlightDto.waypoints is empty; re-plan the mission in the Mission Planner UI"
|
||||||
|
)
|
||||||
|
first = flight.waypoints[0]
|
||||||
|
return LatLonAlt(lat_deg=first.lat_deg, lon_deg=first.lon_deg, alt_m=first.alt_m)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""C12 ``FlightsApiClient`` error hierarchy (AZ-489).
|
||||||
|
|
||||||
|
Mapped 1:1 to the failure modes in the
|
||||||
|
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||||
|
exception table.
|
||||||
|
|
||||||
|
FAC-INV-7 (auth-token redaction): ``FlightsApiAuthError`` overrides
|
||||||
|
``__str__`` and ``__repr__`` to never include the token even if the caller
|
||||||
|
constructs it with one. Other error classes never receive the token in the
|
||||||
|
first place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EmptyWaypointsError",
|
||||||
|
"FlightFileNotFoundError",
|
||||||
|
"FlightNotFoundError",
|
||||||
|
"FlightsApiAuthError",
|
||||||
|
"FlightsApiError",
|
||||||
|
"FlightsApiSchemaError",
|
||||||
|
"FlightsApiUnreachableError",
|
||||||
|
"WaypointSchemaError",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FlightsApiError(Exception):
|
||||||
|
"""Base class for every :class:`FlightsApiClient` failure mode."""
|
||||||
|
|
||||||
|
|
||||||
|
class FlightsApiUnreachableError(FlightsApiError):
|
||||||
|
"""HTTPS connect failure or persistent 5xx after the single allowed retry.
|
||||||
|
|
||||||
|
Operator should retry the online path once network recovers, or fall
|
||||||
|
back to ``--flight-file`` (offline JSON).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FlightsApiAuthError(FlightsApiError):
|
||||||
|
"""HTTP 401 / 403 from the flights REST service.
|
||||||
|
|
||||||
|
Never retried; never logs the offending token. The token field is
|
||||||
|
deliberately excluded from ``__str__`` / ``__repr__`` so a caller
|
||||||
|
``repr()``-logging the exception cannot leak it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FlightNotFoundError(FlightsApiError):
|
||||||
|
"""HTTP 404 — the supplied ``flight_id`` does not exist on the service."""
|
||||||
|
|
||||||
|
|
||||||
|
class FlightsApiSchemaError(FlightsApiError):
|
||||||
|
"""Response body (online) or JSON file (offline) violates the DTO schema."""
|
||||||
|
|
||||||
|
|
||||||
|
class FlightFileNotFoundError(FlightsApiError):
|
||||||
|
"""``--flight-file`` path does not exist on disk."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyWaypointsError(FlightsApiError):
|
||||||
|
"""Resolved flight carries zero waypoints — operator must re-plan in the UI."""
|
||||||
|
|
||||||
|
|
||||||
|
class WaypointSchemaError(FlightsApiError):
|
||||||
|
"""A single waypoint inside the response is malformed.
|
||||||
|
|
||||||
|
Examples: ``lat_deg`` out of ``[-90, 90]``; ``alt_m`` non-finite;
|
||||||
|
negative ``ordinal``; a gap in the ordinal sequence.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Offline JSON :class:`FlightDto` loader (AZ-489).
|
||||||
|
|
||||||
|
The ``--flight-file`` CLI flag in AZ-326 lands here when the operator
|
||||||
|
workstation has no path to the parent-suite ``flights`` REST service. The
|
||||||
|
file format is the same JSON shape the online endpoint returns (FAC-INV-1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||||
|
parse_flight_payload,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||||
|
FlightFileNotFoundError,
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||||
|
FlightDto,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["load_flight_file"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_flight_file(*, path: Path) -> FlightDto:
|
||||||
|
"""Load a :class:`FlightDto` from a JSON file on disk.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FlightFileNotFoundError: ``path`` does not exist.
|
||||||
|
FlightsApiSchemaError: the file is not well-formed JSON OR the
|
||||||
|
decoded payload violates the DTO shape.
|
||||||
|
WaypointSchemaError: an individual waypoint inside the file is
|
||||||
|
malformed.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
raise FlightFileNotFoundError(f"flight file {path!s} does not exist")
|
||||||
|
raw = path.read_bytes()
|
||||||
|
try:
|
||||||
|
payload = orjson.loads(raw)
|
||||||
|
except orjson.JSONDecodeError as exc:
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"flight file {path!s}: not valid JSON: {exc}"
|
||||||
|
) from exc
|
||||||
|
return parse_flight_payload(payload, source_label=f"flight file {path!s}")
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""``HttpxFlightsApiClient`` — concrete :class:`FlightsApiClient` (AZ-489).
|
||||||
|
|
||||||
|
Online path uses ``httpx`` and is unit-tested via ``httpx.MockTransport``.
|
||||||
|
Offline path delegates to :func:`load_flight_file`. The auth token is never
|
||||||
|
emitted to logs (FAC-INV-7); structured log records carry the redacted
|
||||||
|
``"<redacted>"`` marker.
|
||||||
|
|
||||||
|
Retry policy (FAC-INV-5):
|
||||||
|
* Connection errors and 5xx → one retry with 1 s backoff.
|
||||||
|
* 401 / 403 / 404 / schema failures → never retried.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||||
|
parse_flight_payload,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||||
|
bbox_from_waypoints,
|
||||||
|
takeoff_origin_from_flight,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||||
|
FlightNotFoundError,
|
||||||
|
FlightsApiAuthError,
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
FlightsApiUnreachableError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||||
|
load_flight_file,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||||
|
FlightDto,
|
||||||
|
WaypointDto,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.logging import get_logger
|
||||||
|
|
||||||
|
__all__ = ["HttpxFlightsApiClient"]
|
||||||
|
|
||||||
|
|
||||||
|
_REDACTED: Final[str] = "<redacted>"
|
||||||
|
_RETRY_BACKOFF_S: Final[float] = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxFlightsApiClient:
|
||||||
|
"""Concrete :class:`FlightsApiClient` against the parent-suite ``flights`` REST API.
|
||||||
|
|
||||||
|
``transport`` is an optional ``httpx.BaseTransport`` injected by tests
|
||||||
|
(``httpx.MockTransport``). Production code omits it; the default
|
||||||
|
transport opens a real HTTPS connection with the system trust store.
|
||||||
|
|
||||||
|
``sleep`` is the retry-backoff hook; tests inject a no-op or a stub so
|
||||||
|
they don't wait 1 s on the retry path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
sleep: object = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
self._transport = transport
|
||||||
|
self._sleep = sleep
|
||||||
|
self._log = get_logger("c12.flights_api")
|
||||||
|
|
||||||
|
def fetch_flight(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
flight_id: UUID,
|
||||||
|
base_url: str,
|
||||||
|
auth_token: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> FlightDto:
|
||||||
|
url = self._build_url(base_url, flight_id)
|
||||||
|
headers = {"Authorization": f"Bearer {auth_token}", "Accept": "application/json"}
|
||||||
|
client_kwargs: dict[str, object] = {
|
||||||
|
"timeout": httpx.Timeout(timeout_s),
|
||||||
|
}
|
||||||
|
if self._transport is not None:
|
||||||
|
client_kwargs["transport"] = self._transport
|
||||||
|
with httpx.Client(**client_kwargs) as client: # type: ignore[arg-type]
|
||||||
|
response = self._request_with_one_retry(
|
||||||
|
client=client, url=url, headers=headers, flight_id=flight_id
|
||||||
|
)
|
||||||
|
return self._parse_response(response, flight_id=flight_id)
|
||||||
|
|
||||||
|
def load_flight_file(self, *, path: Path) -> FlightDto:
|
||||||
|
return load_flight_file(path=path)
|
||||||
|
|
||||||
|
def bbox_from_waypoints(
|
||||||
|
self,
|
||||||
|
waypoints: tuple[WaypointDto, ...],
|
||||||
|
*,
|
||||||
|
buffer_m: float = 1000.0,
|
||||||
|
) -> BoundingBox:
|
||||||
|
return bbox_from_waypoints(waypoints, buffer_m=buffer_m)
|
||||||
|
|
||||||
|
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt:
|
||||||
|
return takeoff_origin_from_flight(flight)
|
||||||
|
|
||||||
|
def _request_with_one_retry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: httpx.Client,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
flight_id: UUID,
|
||||||
|
) -> httpx.Response:
|
||||||
|
try:
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
||||||
|
return self._retry_after_transient(
|
||||||
|
client=client, url=url, headers=headers, flight_id=flight_id, reason=str(exc)
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
|
||||||
|
raise FlightsApiAuthError(
|
||||||
|
f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
|
||||||
|
f"(http_status={response.status_code})"
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
|
||||||
|
raise FlightNotFoundError(
|
||||||
|
f"flights service has no flight with flight_id={flight_id} (http 404)"
|
||||||
|
)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
return self._retry_after_transient(
|
||||||
|
client=client,
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
flight_id=flight_id,
|
||||||
|
reason=f"http_status={response.status_code}",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _retry_after_transient(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: httpx.Client,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
flight_id: UUID,
|
||||||
|
reason: str,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self._log.warning(
|
||||||
|
"c12.flights.fetch.retry",
|
||||||
|
extra={
|
||||||
|
"kind": "c12.flights.fetch.retry",
|
||||||
|
"kv": {
|
||||||
|
"flight_id": str(flight_id),
|
||||||
|
"reason": reason,
|
||||||
|
"backoff_s": _RETRY_BACKOFF_S,
|
||||||
|
"auth_token": _REDACTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._sleep(_RETRY_BACKOFF_S) # type: ignore[operator]
|
||||||
|
try:
|
||||||
|
response = client.get(url, headers=headers)
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
||||||
|
self._log_failure("c12.flights.fetch.failed", flight_id, None, f"connect:{exc}")
|
||||||
|
raise FlightsApiUnreachableError(
|
||||||
|
f"flights service unreachable for flight_id={flight_id} after one retry: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
self._log_failure("c12.flights.fetch.failed", flight_id, response.status_code, "auth")
|
||||||
|
raise FlightsApiAuthError(
|
||||||
|
f"flights service rejected auth_token={_REDACTED} for flight_id={flight_id} "
|
||||||
|
f"(http_status={response.status_code})"
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
self._log_failure("c12.flights.fetch.failed", flight_id, 404, "not_found")
|
||||||
|
raise FlightNotFoundError(
|
||||||
|
f"flights service has no flight with flight_id={flight_id} (http 404)"
|
||||||
|
)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
self._log_failure(
|
||||||
|
"c12.flights.fetch.failed", flight_id, response.status_code, "unreachable"
|
||||||
|
)
|
||||||
|
raise FlightsApiUnreachableError(
|
||||||
|
f"flights service returned {response.status_code} for flight_id={flight_id} "
|
||||||
|
f"after one retry"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _parse_response(self, response: httpx.Response, *, flight_id: UUID) -> FlightDto:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
self._log_failure(
|
||||||
|
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:not_json"
|
||||||
|
)
|
||||||
|
raise FlightsApiSchemaError(
|
||||||
|
f"flights service returned non-JSON body for flight_id={flight_id}: {exc}"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
flight = parse_flight_payload(payload, source_label="flights service")
|
||||||
|
except FlightsApiSchemaError:
|
||||||
|
self._log_failure(
|
||||||
|
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:flight"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self._log_failure(
|
||||||
|
"c12.flights.fetch.failed", flight_id, response.status_code, "schema:waypoint"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
self._log.info(
|
||||||
|
"c12.flights.fetch.success",
|
||||||
|
extra={
|
||||||
|
"kind": "c12.flights.fetch.success",
|
||||||
|
"kv": {
|
||||||
|
"flight_id": str(flight.flight_id),
|
||||||
|
"name": flight.name,
|
||||||
|
"waypoint_count": len(flight.waypoints),
|
||||||
|
"auth_token": _REDACTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return flight
|
||||||
|
|
||||||
|
def _log_failure(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
flight_id: UUID,
|
||||||
|
http_status: int | None,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
self._log.error(
|
||||||
|
kind,
|
||||||
|
extra={
|
||||||
|
"kind": kind,
|
||||||
|
"kv": {
|
||||||
|
"flight_id": str(flight_id),
|
||||||
|
"http_status": http_status,
|
||||||
|
"reason": reason,
|
||||||
|
"auth_token": _REDACTED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_url(base_url: str, flight_id: UUID) -> str:
|
||||||
|
return base_url.rstrip("/") + f"/flights/{flight_id}"
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""C12 ``FlightsApiClient`` Protocol + DTOs + enums (AZ-489).
|
||||||
|
|
||||||
|
Frozen by ``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||||
|
v1.0.0. The DTOs mirror ``suite/flights/Database/Entities/{Flight,Waypoint}.cs``;
|
||||||
|
adding a new field on the parent-suite C# side requires a new minor-version
|
||||||
|
bump here (FAC-INV-1: online + offline produce the same shape).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FlightDto",
|
||||||
|
"FlightsApiClient",
|
||||||
|
"WaypointDto",
|
||||||
|
"WaypointObjective",
|
||||||
|
"WaypointSource",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WaypointObjective(Enum):
|
||||||
|
"""Mission-planning intent attached to a single waypoint.
|
||||||
|
|
||||||
|
Mirrors ``suite/flights/Database/Entities/WaypointObjective.cs``. Unknown
|
||||||
|
values raise :class:`WaypointSchemaError` during parsing per FAC-INV-1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TAKEOFF = "takeoff"
|
||||||
|
WAYPOINT = "waypoint"
|
||||||
|
LOITER = "loiter"
|
||||||
|
LANDING = "landing"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class WaypointSource(Enum):
|
||||||
|
"""Origin of the waypoint per the parent-suite enum."""
|
||||||
|
|
||||||
|
OPERATOR = "operator"
|
||||||
|
IMPORT = "import"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WaypointDto:
|
||||||
|
"""A single ordered waypoint inside a :class:`FlightDto`.
|
||||||
|
|
||||||
|
``ordinal`` is the strictly-ascending sort key inside the parent flight;
|
||||||
|
parsing enforces a contiguous ``0..N-1`` sequence (FAC-INV-2).
|
||||||
|
``alt_m`` is the WGS84 ellipsoidal height in metres.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ordinal: int
|
||||||
|
lat_deg: float
|
||||||
|
lon_deg: float
|
||||||
|
alt_m: float
|
||||||
|
objective: WaypointObjective
|
||||||
|
source: WaypointSource
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FlightDto:
|
||||||
|
"""An operator-planned mission resolved from the flights service or a file.
|
||||||
|
|
||||||
|
``waypoints`` is non-empty and ordered by ascending ``ordinal``
|
||||||
|
(FAC-INV-2). ``waypoints[0]`` is the takeoff origin per ADR-010 — see
|
||||||
|
:func:`takeoff_origin_from_flight`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
flight_id: UUID
|
||||||
|
name: str
|
||||||
|
waypoints: tuple[WaypointDto, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class FlightsApiClient(Protocol):
|
||||||
|
"""Read a :class:`FlightDto` from the parent-suite flights service or a file.
|
||||||
|
|
||||||
|
Pure read; no side effects beyond structured logging. The caller (C12
|
||||||
|
``CacheBuildWorkflow``) decides which source to use based on CLI flags
|
||||||
|
(``--flight-id`` vs ``--flight-file``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fetch_flight(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
flight_id: UUID,
|
||||||
|
base_url: str,
|
||||||
|
auth_token: str,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
) -> FlightDto:
|
||||||
|
"""Resolve via HTTPS against the parent-suite ``flights`` service.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FlightsApiUnreachableError: HTTPS timeout / 5xx after the
|
||||||
|
single allowed retry (FAC-INV-5).
|
||||||
|
FlightsApiAuthError: HTTP 401 / 403 (never retried; never logs
|
||||||
|
``auth_token``).
|
||||||
|
FlightNotFoundError: HTTP 404 — operator gave a wrong GUID.
|
||||||
|
FlightsApiSchemaError: response body violates the DTO schema.
|
||||||
|
WaypointSchemaError: a waypoint inside the response is malformed.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def load_flight_file(self, *, path: Path) -> FlightDto:
|
||||||
|
"""Resolve from a JSON export on disk (offline path).
|
||||||
|
|
||||||
|
Returns a DTO with the SAME shape as :meth:`fetch_flight` (FAC-INV-1).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def bbox_from_waypoints(
|
||||||
|
self,
|
||||||
|
waypoints: tuple[WaypointDto, ...],
|
||||||
|
*,
|
||||||
|
buffer_m: float = 1000.0,
|
||||||
|
) -> BoundingBox:
|
||||||
|
"""Envelope ``waypoints`` lat/lon and inflate by ``buffer_m`` horizontal metres.
|
||||||
|
|
||||||
|
FAC-INV-3: buffer is a horizontal-distance expansion via
|
||||||
|
``WgsConverter`` ENU round-trip, NOT a degree-space expansion.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt:
|
||||||
|
"""Return ``waypoints[0]`` as a :class:`LatLonAlt` — no rounding (FAC-INV-4)."""
|
||||||
|
...
|
||||||
@@ -539,6 +539,10 @@ class FileFdrWriter:
|
|||||||
if seg_path.exists() and seg_path.stat().st_size == 0:
|
if seg_path.exists() and seg_path.stat().st_size == 0:
|
||||||
seg_path.unlink()
|
seg_path.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# Rollback unlink is best-effort: a zero-byte stray segment is
|
||||||
|
# harmless (the next open_flight scans + skips empty segments),
|
||||||
|
# so any unlink failure here MUST NOT mask the underlying
|
||||||
|
# FdrOpenError that this _fail_open call is about to raise.
|
||||||
pass
|
pass
|
||||||
self._release_filelock()
|
self._release_filelock()
|
||||||
self._started = False
|
self._started = False
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class C4PoseConfig:
|
|||||||
* ``ransac_reprojection_threshold_px`` — RANSAC inlier-distance
|
* ``ransac_reprojection_threshold_px`` — RANSAC inlier-distance
|
||||||
threshold. Default 4.0 pixels per the contract.
|
threshold. Default 4.0 pixels per the contract.
|
||||||
* ``thermal_throttle_threshold_celsius`` — informational only;
|
* ``thermal_throttle_threshold_celsius`` — informational only;
|
||||||
the actual ``ThermalState.throttle`` decision is owned by C7
|
the actual ``ThermalState.thermal_throttle_active`` decision is owned by C7
|
||||||
(AZ-302). Default 75.0 °C.
|
(AZ-302). Default 75.0 °C.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class PoseEstimator(Protocol):
|
|||||||
) -> PoseEstimate:
|
) -> PoseEstimate:
|
||||||
"""Run PnP → factor add → covariance recovery.
|
"""Run PnP → factor add → covariance recovery.
|
||||||
|
|
||||||
Per-frame thermal decision: ``thermal_state.throttle == True``
|
Per-frame thermal decision: ``thermal_state.thermal_throttle_active == True``
|
||||||
engages the Jacobian path (cheap, ~5-10 % accuracy loss);
|
engages the Jacobian path (cheap, ~5-10 % accuracy loss);
|
||||||
``False`` engages the Marginals path (production default).
|
``False`` engages the Marginals path (production default).
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from gps_denied_onboard._types.state import (
|
|||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||||
from gps_denied_onboard.components.c5_state.errors import (
|
from gps_denied_onboard.components.c5_state.errors import (
|
||||||
|
EstimatorAlreadyStartedError,
|
||||||
EstimatorDegradedError,
|
EstimatorDegradedError,
|
||||||
EstimatorFatalError,
|
EstimatorFatalError,
|
||||||
StateEstimatorConfigError,
|
StateEstimatorConfigError,
|
||||||
@@ -34,6 +35,7 @@ from gps_denied_onboard.config.schema import register_component_block
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"C5StateConfig",
|
"C5StateConfig",
|
||||||
|
"EstimatorAlreadyStartedError",
|
||||||
"EstimatorDegradedError",
|
"EstimatorDegradedError",
|
||||||
"EstimatorFatalError",
|
"EstimatorFatalError",
|
||||||
"EstimatorHealth",
|
"EstimatorHealth",
|
||||||
|
|||||||
@@ -66,12 +66,28 @@ class ISam2GraphHandleImpl(ISam2GraphHandle):
|
|||||||
(``EstimatorDegradedError`` for recoverable graph-add issues,
|
(``EstimatorDegradedError`` for recoverable graph-add issues,
|
||||||
``EstimatorFatalError`` for solver failures the calling thread
|
``EstimatorFatalError`` for solver failures the calling thread
|
||||||
cannot recover from).
|
cannot recover from).
|
||||||
|
|
||||||
|
This impl also satisfies C4's narrower consumer-side
|
||||||
|
``c4_pose._isam2_handle.ISam2GraphHandle`` Protocol (which
|
||||||
|
requires only ``get_pose_key(frame_id) -> int``) so the same
|
||||||
|
instance can be passed from ``state_factory`` to
|
||||||
|
``pose_factory.build_pose_estimator`` without an adapter.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, estimator: GtsamIsam2StateEstimator) -> None:
|
def __init__(self, estimator: GtsamIsam2StateEstimator) -> None:
|
||||||
self._estimator = estimator
|
self._estimator = estimator
|
||||||
self._log = get_logger("c5_state.isam2_handle")
|
self._log = get_logger("c5_state.isam2_handle")
|
||||||
|
|
||||||
|
def get_pose_key(self, frame_id: int) -> int:
|
||||||
|
"""Map a C4 ``frame_id`` to the GTSAM pose key.
|
||||||
|
|
||||||
|
Satisfies the C4 ``ISam2GraphHandle`` Protocol stub
|
||||||
|
(AZ-355) by delegating to the estimator's
|
||||||
|
``key_for_frame`` — the canonical C5-side name for the
|
||||||
|
same ``'x'`` namespace lookup.
|
||||||
|
"""
|
||||||
|
return self._estimator.key_for_frame(frame_id)
|
||||||
|
|
||||||
def add_factor(self, factor: Any) -> None:
|
def add_factor(self, factor: Any) -> None:
|
||||||
"""Append ``factor`` to the pending ``NonlinearFactorGraph``.
|
"""Append ``factor`` to the pending ``NonlinearFactorGraph``.
|
||||||
|
|
||||||
|
|||||||
@@ -43,18 +43,30 @@ from typing import TYPE_CHECKING, Final, Protocol, runtime_checkable
|
|||||||
from gps_denied_onboard._types.fc import GpsStatus, Severity
|
from gps_denied_onboard._types.fc import GpsStatus, Severity
|
||||||
from gps_denied_onboard._types.state import PoseSourceLabel
|
from gps_denied_onboard._types.state import PoseSourceLabel
|
||||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
from gps_denied_onboard.logging import get_logger
|
from gps_denied_onboard.logging import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gps_denied_onboard._types.fc import GpsHealth
|
from gps_denied_onboard._types.fc import GpsHealth
|
||||||
|
from gps_denied_onboard._types.geo import LatLonAlt
|
||||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"BOUNDED_DELTA_REJECT",
|
||||||
|
"BOUNDED_DELTA_SOFT",
|
||||||
"RejectionCallback",
|
"RejectionCallback",
|
||||||
"RejectionSubscription",
|
"RejectionSubscription",
|
||||||
"SourceLabelStateMachine",
|
"SourceLabelStateMachine",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# AZ-490 / Principle #11 third clause: tri-state outcome of the
|
||||||
|
# bounded-delta gate. Kept as module-level string constants rather
|
||||||
|
# than additions to ``PoseSourceLabel`` because the public C4/C5
|
||||||
|
# label enum is stable and a soft-admission outcome doesn't change
|
||||||
|
# the externally observable pose-provenance contract.
|
||||||
|
BOUNDED_DELTA_SOFT: Final[str] = "BOUNDED_DELTA_SOFT"
|
||||||
|
BOUNDED_DELTA_REJECT: Final[str] = "BOUNDED_DELTA_REJECT"
|
||||||
|
|
||||||
|
|
||||||
# Subscriber signature — composition root receives
|
# Subscriber signature — composition root receives
|
||||||
# (reason, severity, statustext) on every reject. ``severity`` is
|
# (reason, severity, statustext) on every reject. ``severity`` is
|
||||||
@@ -140,6 +152,7 @@ class SourceLabelStateMachine:
|
|||||||
*,
|
*,
|
||||||
spoof_promotion_min_stable_s: float,
|
spoof_promotion_min_stable_s: float,
|
||||||
spoof_promotion_visual_consistency_tol_m: float,
|
spoof_promotion_visual_consistency_tol_m: float,
|
||||||
|
spoof_promotion_bounded_delta_m: float,
|
||||||
fdr_client: FdrClient | None,
|
fdr_client: FdrClient | None,
|
||||||
producer_id: str = "c5_state",
|
producer_id: str = "c5_state",
|
||||||
clock_ns: Callable[[], int] = time.monotonic_ns,
|
clock_ns: Callable[[], int] = time.monotonic_ns,
|
||||||
@@ -154,8 +167,14 @@ class SourceLabelStateMachine:
|
|||||||
"SourceLabelStateMachine.spoof_promotion_visual_consistency_tol_m "
|
"SourceLabelStateMachine.spoof_promotion_visual_consistency_tol_m "
|
||||||
f"must be > 0; got {spoof_promotion_visual_consistency_tol_m}"
|
f"must be > 0; got {spoof_promotion_visual_consistency_tol_m}"
|
||||||
)
|
)
|
||||||
|
if spoof_promotion_bounded_delta_m <= 0.0:
|
||||||
|
raise ValueError(
|
||||||
|
"SourceLabelStateMachine.spoof_promotion_bounded_delta_m must be > 0; "
|
||||||
|
f"got {spoof_promotion_bounded_delta_m}"
|
||||||
|
)
|
||||||
self._min_stable_ns: int = int(spoof_promotion_min_stable_s * 1_000_000_000)
|
self._min_stable_ns: int = int(spoof_promotion_min_stable_s * 1_000_000_000)
|
||||||
self._consistency_tol_m: float = spoof_promotion_visual_consistency_tol_m
|
self._consistency_tol_m: float = spoof_promotion_visual_consistency_tol_m
|
||||||
|
self._bounded_delta_m: float = spoof_promotion_bounded_delta_m
|
||||||
self._fdr_client: FdrClient | None = fdr_client
|
self._fdr_client: FdrClient | None = fdr_client
|
||||||
self._producer_id: str = producer_id
|
self._producer_id: str = producer_id
|
||||||
self._clock_ns: Callable[[], int] = clock_ns
|
self._clock_ns: Callable[[], int] = clock_ns
|
||||||
@@ -322,6 +341,115 @@ class SourceLabelStateMachine:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# AZ-490: third clause — bounded-delta GPS gate.
|
||||||
|
|
||||||
|
def process_gps_sample(
|
||||||
|
self,
|
||||||
|
sample: LatLonAlt,
|
||||||
|
*,
|
||||||
|
smoother_estimate: LatLonAlt | None,
|
||||||
|
now_ns: int | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Bounded-delta admission test for an inbound FC GPS sample.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
* :data:`BOUNDED_DELTA_SOFT` — sample is within the
|
||||||
|
``spoof_promotion_bounded_delta_m`` ring; the composition
|
||||||
|
root may attach it as a soft pose-anchor factor.
|
||||||
|
* :data:`BOUNDED_DELTA_REJECT` — sample is outside the ring;
|
||||||
|
the composition root MUST drop it; the dwell-time clause
|
||||||
|
(clause 1 of the spoof-promotion gate) is reset so a
|
||||||
|
subsequent reanchor must wait the full
|
||||||
|
``spoof_promotion_min_stable_s`` window.
|
||||||
|
* ``None`` — no smoother estimate is available yet (cold
|
||||||
|
start / fallback engaged); the gate is skipped and the
|
||||||
|
caller decides whether to admit or drop.
|
||||||
|
|
||||||
|
AC-15: distance is computed via :meth:`WgsConverter.horizontal_distance_m`,
|
||||||
|
which routes through ``pyproj``'s ECEF chain (matches Vincenty
|
||||||
|
within sub-mm at the bounded-delta operating range), so the
|
||||||
|
haversine-on-equirectangular shortcut is excluded.
|
||||||
|
"""
|
||||||
|
if smoother_estimate is None:
|
||||||
|
return None
|
||||||
|
ts = now_ns if now_ns is not None else self._clock_ns()
|
||||||
|
try:
|
||||||
|
distance_m = WgsConverter.horizontal_distance_m(smoother_estimate, sample)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log.error(
|
||||||
|
"c5.gps_bounded_delta.distance_failed",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.gps_bounded_delta.distance_failed",
|
||||||
|
"kv": {"error": str(exc)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
threshold_m = self._bounded_delta_m
|
||||||
|
admitted = distance_m <= threshold_m
|
||||||
|
kind = (
|
||||||
|
"c5.gps_bounded_delta.accept" if admitted else "c5.gps_bounded_delta.reject"
|
||||||
|
)
|
||||||
|
record = FdrRecord(
|
||||||
|
schema_version=1,
|
||||||
|
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||||
|
producer_id=self._producer_id,
|
||||||
|
kind=kind,
|
||||||
|
payload={
|
||||||
|
"sample_lat": sample.lat_deg,
|
||||||
|
"sample_lon": sample.lon_deg,
|
||||||
|
"smoother_lat": smoother_estimate.lat_deg,
|
||||||
|
"smoother_lon": smoother_estimate.lon_deg,
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"threshold_m": threshold_m,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if self._fdr_client is not None:
|
||||||
|
try:
|
||||||
|
self._fdr_client.enqueue(record)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log.warning(
|
||||||
|
"c5.gps_bounded_delta.fdr_enqueue_failed",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.gps_bounded_delta.fdr_enqueue_failed",
|
||||||
|
"kv": {"reject": not admitted, "error": repr(exc)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if admitted:
|
||||||
|
self._log.info(
|
||||||
|
"c5.gps_bounded_delta.accept",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.gps_bounded_delta.accept",
|
||||||
|
"kv": {
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"threshold_m": threshold_m,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return BOUNDED_DELTA_SOFT
|
||||||
|
# Reject path: bump the dwell-time clause so the AZ-385 gate
|
||||||
|
# holds steady — a wildly-off GPS sample is a strong signal
|
||||||
|
# the FC stream is not (yet) reliable, even when health is
|
||||||
|
# nominally STABLE_NON_SPOOFED.
|
||||||
|
with self._lock:
|
||||||
|
self._gps_health_stable_since_ns = None
|
||||||
|
self._log.warning(
|
||||||
|
"c5.gps_bounded_delta.reject",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.gps_bounded_delta.reject",
|
||||||
|
"kv": {
|
||||||
|
"distance_m": distance_m,
|
||||||
|
"threshold_m": threshold_m,
|
||||||
|
"sample_lat": sample.lat_deg,
|
||||||
|
"sample_lon": sample.lon_deg,
|
||||||
|
"smoother_lat": smoother_estimate.lat_deg,
|
||||||
|
"smoother_lon": smoother_estimate.lon_deg,
|
||||||
|
"ts_ns": ts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return BOUNDED_DELTA_REJECT
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Subscription API.
|
# Subscription API.
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ class C5StateConfig:
|
|||||||
in ``STABLE_NON_SPOOFED`` before the spoof-promotion gate opens.
|
in ``STABLE_NON_SPOOFED`` before the spoof-promotion gate opens.
|
||||||
- ``spoof_promotion_visual_consistency_tol_m`` — AC-NEW-8 visual
|
- ``spoof_promotion_visual_consistency_tol_m`` — AC-NEW-8 visual
|
||||||
consistency tolerance on the next anchor.
|
consistency tolerance on the next anchor.
|
||||||
|
- ``spoof_promotion_bounded_delta_m`` — AZ-490 / Principle #11
|
||||||
|
third clause: max horizontal distance (m) between an inbound FC
|
||||||
|
GPS sample and the smoother's current estimate; samples within
|
||||||
|
the ring are admitted as ``BOUNDED_DELTA_SOFT``, samples outside
|
||||||
|
are rejected and counted against the dwell-time clause.
|
||||||
|
- ``default_takeoff_origin_sigma_horiz_m`` — AZ-490 default
|
||||||
|
horizontal sigma when ``set_takeoff_origin`` callers omit one.
|
||||||
|
- ``default_takeoff_origin_sigma_vert_m`` — AZ-490 default
|
||||||
|
vertical sigma when ``set_takeoff_origin`` callers omit one.
|
||||||
- ``no_estimate_fallback_s`` — AC-5.2 timeout before the
|
- ``no_estimate_fallback_s`` — AC-5.2 timeout before the
|
||||||
runtime root drops to FC-IMU-only mode.
|
runtime root drops to FC-IMU-only mode.
|
||||||
"""
|
"""
|
||||||
@@ -45,6 +54,9 @@ class C5StateConfig:
|
|||||||
keyframe_window_size: int = 15
|
keyframe_window_size: int = 15
|
||||||
spoof_promotion_min_stable_s: float = 10.0
|
spoof_promotion_min_stable_s: float = 10.0
|
||||||
spoof_promotion_visual_consistency_tol_m: float = 30.0
|
spoof_promotion_visual_consistency_tol_m: float = 30.0
|
||||||
|
spoof_promotion_bounded_delta_m: float = 200.0
|
||||||
|
default_takeoff_origin_sigma_horiz_m: float = 5.0
|
||||||
|
default_takeoff_origin_sigma_vert_m: float = 10.0
|
||||||
no_estimate_fallback_s: float = 3.0
|
no_estimate_fallback_s: float = 3.0
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
@@ -68,6 +80,21 @@ class C5StateConfig:
|
|||||||
"C5StateConfig.spoof_promotion_visual_consistency_tol_m must be > 0; "
|
"C5StateConfig.spoof_promotion_visual_consistency_tol_m must be > 0; "
|
||||||
f"got {self.spoof_promotion_visual_consistency_tol_m}"
|
f"got {self.spoof_promotion_visual_consistency_tol_m}"
|
||||||
)
|
)
|
||||||
|
if self.spoof_promotion_bounded_delta_m <= 0.0:
|
||||||
|
raise ConfigError(
|
||||||
|
"C5StateConfig.spoof_promotion_bounded_delta_m must be > 0; "
|
||||||
|
f"got {self.spoof_promotion_bounded_delta_m}"
|
||||||
|
)
|
||||||
|
if self.default_takeoff_origin_sigma_horiz_m <= 0.0:
|
||||||
|
raise ConfigError(
|
||||||
|
"C5StateConfig.default_takeoff_origin_sigma_horiz_m must be > 0; "
|
||||||
|
f"got {self.default_takeoff_origin_sigma_horiz_m}"
|
||||||
|
)
|
||||||
|
if self.default_takeoff_origin_sigma_vert_m <= 0.0:
|
||||||
|
raise ConfigError(
|
||||||
|
"C5StateConfig.default_takeoff_origin_sigma_vert_m must be > 0; "
|
||||||
|
f"got {self.default_takeoff_origin_sigma_vert_m}"
|
||||||
|
)
|
||||||
if self.no_estimate_fallback_s <= 0.0:
|
if self.no_estimate_fallback_s <= 0.0:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"C5StateConfig.no_estimate_fallback_s must be > 0; "
|
"C5StateConfig.no_estimate_fallback_s must be > 0; "
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ in C8).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"EstimatorAlreadyStartedError",
|
||||||
"EstimatorDegradedError",
|
"EstimatorDegradedError",
|
||||||
"EstimatorFatalError",
|
"EstimatorFatalError",
|
||||||
"StateEstimatorConfigError",
|
"StateEstimatorConfigError",
|
||||||
@@ -52,4 +53,22 @@ class StateEstimatorConfigError(StateEstimatorError):
|
|||||||
strategy is not registered (per ADR-002 build flag gating), when
|
strategy is not registered (per ADR-002 build flag gating), when
|
||||||
the config schema fails validation, or when the runtime root
|
the config schema fails validation, or when the runtime root
|
||||||
cannot wire the iSAM2 graph handle into C4.
|
cannot wire the iSAM2 graph handle into C4.
|
||||||
|
|
||||||
|
AZ-490: also raised by :meth:`StateEstimator.set_takeoff_origin`
|
||||||
|
when the supplied ``LatLonAlt`` is outside WGS-84 bounds, when
|
||||||
|
either sigma is non-positive / non-finite, or when the entrypoint
|
||||||
|
is called twice with conflicting arguments before the first
|
||||||
|
measurement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class EstimatorAlreadyStartedError(StateEstimatorConfigError):
|
||||||
|
"""``set_takeoff_origin`` called after the estimator left the INIT state.
|
||||||
|
|
||||||
|
AZ-490 / Contract Invariant 11a: the operator-origin entrypoint is
|
||||||
|
valid only before the first ``add_*`` call. Once any factor has
|
||||||
|
been added (i.e. the smoother is past INIT), seeding a new prior
|
||||||
|
would silently corrupt the running estimate. ``IS-A`` of
|
||||||
|
:class:`StateEstimatorConfigError` so existing
|
||||||
|
``except StateEstimatorConfigError`` callers also catch this.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ filter; this module documents the deviation in the
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from datetime import datetime, timezone
|
||||||
|
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -75,16 +77,18 @@ from gps_denied_onboard.components.c5_state._source_label_sm import (
|
|||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||||
from gps_denied_onboard.components.c5_state.errors import (
|
from gps_denied_onboard.components.c5_state.errors import (
|
||||||
|
EstimatorAlreadyStartedError,
|
||||||
EstimatorDegradedError,
|
EstimatorDegradedError,
|
||||||
EstimatorFatalError,
|
EstimatorFatalError,
|
||||||
StateEstimatorConfigError,
|
StateEstimatorConfigError,
|
||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c5_state.interface import StateEstimator
|
from gps_denied_onboard.components.c5_state.interface import StateEstimator
|
||||||
|
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||||
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
from gps_denied_onboard.logging import get_logger
|
from gps_denied_onboard.logging import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gps_denied_onboard._types.fc import GpsHealth
|
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
|
||||||
from gps_denied_onboard._types.nav import ImuWindow
|
from gps_denied_onboard._types.nav import ImuWindow
|
||||||
from gps_denied_onboard._types.pose import PoseEstimate
|
from gps_denied_onboard._types.pose import PoseEstimate
|
||||||
from gps_denied_onboard._types.vio import VioOutput
|
from gps_denied_onboard._types.vio import VioOutput
|
||||||
@@ -196,11 +200,20 @@ class EskfStateEstimator(StateEstimator):
|
|||||||
self._enu_origin: LatLonAlt | None = None
|
self._enu_origin: LatLonAlt | None = None
|
||||||
self._history: deque[EstimatorOutput] = deque(maxlen=_HISTORY_DEPTH)
|
self._history: deque[EstimatorOutput] = deque(maxlen=_HISTORY_DEPTH)
|
||||||
|
|
||||||
|
# AZ-490 cold-start ladder. Same semantics as
|
||||||
|
# ``GtsamIsam2StateEstimator`` — see that class for the
|
||||||
|
# field-level rationale (idempotency, conflict detection,
|
||||||
|
# window-closed gate, FC-EKF fallback FDR).
|
||||||
|
self._takeoff_origin_set: tuple[LatLonAlt, float, float] | None = None
|
||||||
|
self._origin_source: Literal["manifest", "fc_ekf"] | None = None
|
||||||
|
self._cold_start_window_closed: bool = False
|
||||||
|
|
||||||
# AZ-385: source-label SM. Eagerly constructed; composition
|
# AZ-385: source-label SM. Eagerly constructed; composition
|
||||||
# root drives notify_gps_health + subscribe_spoof_rejection.
|
# root drives notify_gps_health + subscribe_spoof_rejection.
|
||||||
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
||||||
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
||||||
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
||||||
|
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
|
||||||
fdr_client=fdr_client,
|
fdr_client=fdr_client,
|
||||||
producer_id="c5_state",
|
producer_id="c5_state",
|
||||||
)
|
)
|
||||||
@@ -278,6 +291,151 @@ class EskfStateEstimator(StateEstimator):
|
|||||||
) -> FallbackSubscription:
|
) -> FallbackSubscription:
|
||||||
return self._fallback.subscribe_recovered(callback)
|
return self._fallback.subscribe_recovered(callback)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# AZ-490: operator-origin cold-start entrypoint.
|
||||||
|
|
||||||
|
def set_takeoff_origin(
|
||||||
|
self,
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
"""Seed the ESKF nominal state + position covariance from operator origin.
|
||||||
|
|
||||||
|
See :meth:`StateEstimator.set_takeoff_origin` for the full
|
||||||
|
contract. ESKF impl: sets the ENU origin to ``origin``,
|
||||||
|
zeros the nominal position (the operator-supplied origin
|
||||||
|
IS the local-ENU (0,0,0)), and writes the position block of
|
||||||
|
``_P`` to ``diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)``.
|
||||||
|
"""
|
||||||
|
_validate_takeoff_origin_args(origin, sigma_horiz_m, sigma_vert_m)
|
||||||
|
|
||||||
|
if self._cold_start_window_closed:
|
||||||
|
raise EstimatorAlreadyStartedError(
|
||||||
|
"set_takeoff_origin called after the cold-start window closed; "
|
||||||
|
"first add_* call sealed the operator-origin entrypoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._takeoff_origin_set is not None:
|
||||||
|
prev_origin, prev_sh, prev_sv = self._takeoff_origin_set
|
||||||
|
if prev_origin == origin and prev_sh == sigma_horiz_m and prev_sv == sigma_vert_m:
|
||||||
|
return # AC-4 — idempotent no-op
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
"set_takeoff_origin re-called with conflicting args; "
|
||||||
|
f"previous=(origin={prev_origin!r}, sigma_horiz_m={prev_sh}, "
|
||||||
|
f"sigma_vert_m={prev_sv}); "
|
||||||
|
f"new=(origin={origin!r}, sigma_horiz_m={sigma_horiz_m}, "
|
||||||
|
f"sigma_vert_m={sigma_vert_m})"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._enu_origin = origin
|
||||||
|
self._nominal_pos = np.zeros(3, dtype=np.float64)
|
||||||
|
# Position block of the error covariance — explicit overwrite
|
||||||
|
# (the diagonal stays diagonal here; off-diagonal terms in
|
||||||
|
# ``_P[0:3, ...]`` were zero at construction and we don't
|
||||||
|
# want to lose those zeros).
|
||||||
|
pos_var = np.diag(
|
||||||
|
np.array(
|
||||||
|
[sigma_horiz_m**2, sigma_horiz_m**2, sigma_vert_m**2],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._P[_IDX_POS, _IDX_POS] = pos_var
|
||||||
|
# Symmetrise defensively — the rest of the matrix may carry
|
||||||
|
# accumulated cross-terms from earlier construction; the
|
||||||
|
# symmetrise costs nothing and keeps the SPD invariant
|
||||||
|
# observable on the next read.
|
||||||
|
self._P = 0.5 * (self._P + self._P.T)
|
||||||
|
|
||||||
|
self._takeoff_origin_set = (origin, sigma_horiz_m, sigma_vert_m)
|
||||||
|
self._origin_source = "manifest"
|
||||||
|
self._emit_cold_start_origin_set_fdr(
|
||||||
|
source="manifest",
|
||||||
|
origin=origin,
|
||||||
|
sigma_horiz_m=sigma_horiz_m,
|
||||||
|
sigma_vert_m=sigma_vert_m,
|
||||||
|
)
|
||||||
|
self._log.info(
|
||||||
|
"c5.cold_start_origin.set",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.cold_start_origin.set",
|
||||||
|
"kv": {
|
||||||
|
"source": "manifest",
|
||||||
|
"lat_deg": origin.lat_deg,
|
||||||
|
"lon_deg": origin.lon_deg,
|
||||||
|
"alt_m": origin.alt_m,
|
||||||
|
"sigma_horiz_m": sigma_horiz_m,
|
||||||
|
"sigma_vert_m": sigma_vert_m,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close_cold_start_window(self) -> None:
|
||||||
|
"""ESKF mirror of the iSAM2 helper — see that class for the contract."""
|
||||||
|
if self._cold_start_window_closed:
|
||||||
|
return
|
||||||
|
self._cold_start_window_closed = True
|
||||||
|
if self._origin_source is None:
|
||||||
|
self._origin_source = "fc_ekf"
|
||||||
|
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||||
|
self._emit_cold_start_origin_set_fdr(
|
||||||
|
source="fc_ekf",
|
||||||
|
origin=origin,
|
||||||
|
sigma_horiz_m=self._block.default_takeoff_origin_sigma_horiz_m,
|
||||||
|
sigma_vert_m=self._block.default_takeoff_origin_sigma_vert_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_cold_start_origin_set_fdr(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: Literal["manifest", "fc_ekf"],
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
if self._fdr_client is None:
|
||||||
|
return
|
||||||
|
record = FdrRecord(
|
||||||
|
schema_version=1,
|
||||||
|
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||||
|
producer_id="c5_state",
|
||||||
|
kind="c5.cold_start_origin.set",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
"lat_deg": origin.lat_deg,
|
||||||
|
"lon_deg": origin.lon_deg,
|
||||||
|
"alt_m": origin.alt_m,
|
||||||
|
"sigma_horiz_m": sigma_horiz_m,
|
||||||
|
"sigma_vert_m": sigma_vert_m,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._fdr_client.enqueue(record)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log.warning(
|
||||||
|
"c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||||
|
"kv": {"source": source, "error": repr(exc)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def notify_gps_sample(self, sample: GpsSample, now_ns: int | None = None) -> str | None:
|
||||||
|
"""ESKF bounded-delta dispatch — mirror of the iSAM2 method."""
|
||||||
|
machine = self._source_label_machine
|
||||||
|
if not isinstance(machine, SourceLabelStateMachine):
|
||||||
|
return None
|
||||||
|
smoother_latlon: LatLonAlt | None
|
||||||
|
try:
|
||||||
|
smoother_latlon = self._enu_pose_to_wgs84()
|
||||||
|
except Exception:
|
||||||
|
smoother_latlon = None
|
||||||
|
return machine.process_gps_sample(
|
||||||
|
sample.position_wgs84,
|
||||||
|
smoother_estimate=smoother_latlon,
|
||||||
|
now_ns=now_ns,
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Protocol: factor adds.
|
# Protocol: factor adds.
|
||||||
|
|
||||||
@@ -290,6 +448,7 @@ class EskfStateEstimator(StateEstimator):
|
|||||||
the analogous nominal delta — both are projected to a 6-vector
|
the analogous nominal delta — both are projected to a 6-vector
|
||||||
residual in the previous body frame.
|
residual in the previous body frame.
|
||||||
"""
|
"""
|
||||||
|
self._close_cold_start_window()
|
||||||
ts_ns = _datetime_to_ns(vio.timestamp)
|
ts_ns = _datetime_to_ns(vio.timestamp)
|
||||||
self._guard_timestamp(ts_ns, source="vio")
|
self._guard_timestamp(ts_ns, source="vio")
|
||||||
curr_pose = _pose_se3_to_array(vio.pose_se3)
|
curr_pose = _pose_se3_to_array(vio.pose_se3)
|
||||||
@@ -372,6 +531,7 @@ class EskfStateEstimator(StateEstimator):
|
|||||||
has no graph to throttle); it integrates every anchor as a
|
has no graph to throttle); it integrates every anchor as a
|
||||||
regular measurement.
|
regular measurement.
|
||||||
"""
|
"""
|
||||||
|
self._close_cold_start_window()
|
||||||
ts_ns = int(pose.emitted_at)
|
ts_ns = int(pose.emitted_at)
|
||||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||||
meas_pose = self._pose_estimate_to_matrix(pose)
|
meas_pose = self._pose_estimate_to_matrix(pose)
|
||||||
@@ -415,6 +575,7 @@ class EskfStateEstimator(StateEstimator):
|
|||||||
|
|
||||||
def add_fc_imu(self, imu_window: ImuWindow) -> None:
|
def add_fc_imu(self, imu_window: ImuWindow) -> None:
|
||||||
"""Predict nominal state + propagate covariance over the IMU window."""
|
"""Predict nominal state + propagate covariance over the IMU window."""
|
||||||
|
self._close_cold_start_window()
|
||||||
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
||||||
samples = imu_window.samples
|
samples = imu_window.samples
|
||||||
if not samples:
|
if not samples:
|
||||||
@@ -925,6 +1086,38 @@ def _enforce_spd(cov: np.ndarray) -> None:
|
|||||||
raise EstimatorFatalError(f"covariance not SPD: {exc}") from exc
|
raise EstimatorFatalError(f"covariance not SPD: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_takeoff_origin_args(
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
"""AZ-490 input validation — same rules for both estimator impls."""
|
||||||
|
if not (
|
||||||
|
math.isfinite(origin.lat_deg)
|
||||||
|
and math.isfinite(origin.lon_deg)
|
||||||
|
and math.isfinite(origin.alt_m)
|
||||||
|
):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: non-finite component in origin {origin!r}"
|
||||||
|
)
|
||||||
|
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: latitude {origin.lat_deg} outside WGS-84 [-90, 90]"
|
||||||
|
)
|
||||||
|
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: longitude {origin.lon_deg} outside WGS-84 [-180, 180]"
|
||||||
|
)
|
||||||
|
if not (math.isfinite(sigma_horiz_m) and sigma_horiz_m > 0.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: sigma_horiz_m must be positive finite; got {sigma_horiz_m}"
|
||||||
|
)
|
||||||
|
if not (math.isfinite(sigma_vert_m) and sigma_vert_m > 0.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: sigma_vert_m must be positive finite; got {sigma_vert_m}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _with_smoothed_false(out: EstimatorOutput) -> EstimatorOutput:
|
def _with_smoothed_false(out: EstimatorOutput) -> EstimatorOutput:
|
||||||
"""Return a copy of ``out`` with ``smoothed=False``.
|
"""Return a copy of ``out`` with ``smoothed=False``.
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,11 @@ there.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import gtsam
|
import gtsam
|
||||||
@@ -66,6 +67,7 @@ from gps_denied_onboard.components.c5_state._source_label_sm import (
|
|||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
from gps_denied_onboard.components.c5_state.config import C5StateConfig
|
||||||
from gps_denied_onboard.components.c5_state.errors import (
|
from gps_denied_onboard.components.c5_state.errors import (
|
||||||
|
EstimatorAlreadyStartedError,
|
||||||
EstimatorDegradedError,
|
EstimatorDegradedError,
|
||||||
EstimatorFatalError,
|
EstimatorFatalError,
|
||||||
StateEstimatorConfigError,
|
StateEstimatorConfigError,
|
||||||
@@ -76,7 +78,7 @@ from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
|||||||
from gps_denied_onboard.logging import get_logger
|
from gps_denied_onboard.logging import get_logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gps_denied_onboard._types.fc import GpsHealth
|
from gps_denied_onboard._types.fc import GpsHealth, GpsSample
|
||||||
from gps_denied_onboard._types.nav import ImuWindow
|
from gps_denied_onboard._types.nav import ImuWindow
|
||||||
from gps_denied_onboard._types.pose import PoseEstimate
|
from gps_denied_onboard._types.pose import PoseEstimate
|
||||||
from gps_denied_onboard._types.vio import VioOutput
|
from gps_denied_onboard._types.vio import VioOutput
|
||||||
@@ -120,6 +122,13 @@ _COV_NORM_WINDOW_NS: Final[int] = 60 * 1_000_000_000
|
|||||||
# state. AZ-385 will tie origin selection to the spoof-promotion gate.
|
# state. AZ-385 will tie origin selection to the spoof-promotion gate.
|
||||||
_DEFAULT_ENU_ORIGIN: Final[LatLonAlt] = LatLonAlt(lat_deg=0.0, lon_deg=0.0, alt_m=0.0)
|
_DEFAULT_ENU_ORIGIN: Final[LatLonAlt] = LatLonAlt(lat_deg=0.0, lon_deg=0.0, alt_m=0.0)
|
||||||
|
|
||||||
|
# AZ-490 / ADR-010 default rotation sigma for the operator-origin
|
||||||
|
# prior factor. Translation sigmas are caller-supplied; the rotation
|
||||||
|
# component has no operator input, so we use a conservative 5° prior
|
||||||
|
# (a still-stationary drone with no compass calibration is comfortably
|
||||||
|
# inside a 5° envelope at the WGS84 frame).
|
||||||
|
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD: Final[float] = math.radians(5.0)
|
||||||
|
|
||||||
|
|
||||||
class GtsamIsam2StateEstimator(StateEstimator):
|
class GtsamIsam2StateEstimator(StateEstimator):
|
||||||
"""Production-default C5 estimator — iSAM2 + ``IncrementalFixedLagSmoother``.
|
"""Production-default C5 estimator — iSAM2 + ``IncrementalFixedLagSmoother``.
|
||||||
@@ -213,6 +222,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
self._source_label_machine: SourceLabelStateMachine = SourceLabelStateMachine(
|
||||||
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
spoof_promotion_min_stable_s=block.spoof_promotion_min_stable_s,
|
||||||
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
spoof_promotion_visual_consistency_tol_m=block.spoof_promotion_visual_consistency_tol_m,
|
||||||
|
spoof_promotion_bounded_delta_m=block.spoof_promotion_bounded_delta_m,
|
||||||
fdr_client=fdr_client,
|
fdr_client=fdr_client,
|
||||||
producer_id="c5_state",
|
producer_id="c5_state",
|
||||||
)
|
)
|
||||||
@@ -248,6 +258,25 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
producer_id="c5_state",
|
producer_id="c5_state",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AZ-490 cold-start ladder ----------------------------------------
|
||||||
|
# ``_takeoff_origin_set`` records the operator-origin args so
|
||||||
|
# AC-4 idempotency + AC-5 conflict checks can compare without
|
||||||
|
# re-deriving from the seeded ``PriorFactorPose3``. None means
|
||||||
|
# ``set_takeoff_origin`` has not been called yet.
|
||||||
|
self._takeoff_origin_set: tuple[LatLonAlt, float, float] | None = None
|
||||||
|
# ``_origin_source`` tracks which cold-start anchor won.
|
||||||
|
# ``"manifest"`` is set by ``set_takeoff_origin`` (Principle
|
||||||
|
# #11 primary); ``"fc_ekf"`` is set by the first ``add_*``
|
||||||
|
# call when no manifest origin was supplied (legacy fallback).
|
||||||
|
# AC-13 wants exactly one ``c5.cold_start_origin.set`` FDR
|
||||||
|
# record per estimator; this field gates the emission.
|
||||||
|
self._origin_source: Literal["manifest", "fc_ekf"] | None = None
|
||||||
|
# ``_cold_start_window_closed`` flips True on the first
|
||||||
|
# ``add_*`` call (vio / pose_anchor / fc_imu). After it
|
||||||
|
# closes, ``set_takeoff_origin`` must raise
|
||||||
|
# ``EstimatorAlreadyStartedError`` (AC-6).
|
||||||
|
self._cold_start_window_closed: bool = False
|
||||||
|
|
||||||
self._log.debug(
|
self._log.debug(
|
||||||
"c5.state.isam2_initialised",
|
"c5.state.isam2_initialised",
|
||||||
extra={
|
extra={
|
||||||
@@ -392,6 +421,224 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
self._next_key_counter += 1
|
self._next_key_counter += 1
|
||||||
return new_key
|
return new_key
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# AZ-490: operator-origin cold-start entrypoint.
|
||||||
|
|
||||||
|
def set_takeoff_origin(
|
||||||
|
self,
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
"""Seed the cold-start prior with an operator-supplied origin (AZ-490).
|
||||||
|
|
||||||
|
See :meth:`StateEstimator.set_takeoff_origin` for the full
|
||||||
|
contract. This impl sets the local ENU origin to ``origin``,
|
||||||
|
attaches one ``PriorFactorPose3`` at ``Pose3.Identity()`` to
|
||||||
|
the next pose key, drives a single ``handle.update``, and
|
||||||
|
emits one ``c5.cold_start_origin.set`` FDR record.
|
||||||
|
"""
|
||||||
|
self._validate_takeoff_origin_args(origin, sigma_horiz_m, sigma_vert_m)
|
||||||
|
|
||||||
|
if self._cold_start_window_closed:
|
||||||
|
raise EstimatorAlreadyStartedError(
|
||||||
|
"set_takeoff_origin called after the cold-start window closed; "
|
||||||
|
"first add_* call sealed the operator-origin entrypoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._takeoff_origin_set is not None:
|
||||||
|
prev_origin, prev_sh, prev_sv = self._takeoff_origin_set
|
||||||
|
if prev_origin == origin and prev_sh == sigma_horiz_m and prev_sv == sigma_vert_m:
|
||||||
|
return # AC-4 — idempotent no-op
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
"set_takeoff_origin re-called with conflicting args; "
|
||||||
|
f"previous=(origin={prev_origin!r}, sigma_horiz_m={prev_sh}, "
|
||||||
|
f"sigma_vert_m={prev_sv}); "
|
||||||
|
f"new=(origin={origin!r}, sigma_horiz_m={sigma_horiz_m}, "
|
||||||
|
f"sigma_vert_m={sigma_vert_m})"
|
||||||
|
)
|
||||||
|
|
||||||
|
handle = self._require_handle()
|
||||||
|
self._enu_origin = origin
|
||||||
|
|
||||||
|
prior_pose = gtsam.Pose3() # Identity at ENU origin
|
||||||
|
prior_key = gtsam.symbol("x", self._next_key_counter)
|
||||||
|
self._next_key_counter += 1
|
||||||
|
|
||||||
|
sigmas = np.array(
|
||||||
|
[
|
||||||
|
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||||
|
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||||
|
_DEFAULT_TAKEOFF_ORIENTATION_SIGMA_RAD,
|
||||||
|
sigma_horiz_m,
|
||||||
|
sigma_horiz_m,
|
||||||
|
sigma_vert_m,
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
noise = gtsam.noiseModel.Diagonal.Sigmas(sigmas)
|
||||||
|
factor = gtsam.PriorFactorPose3(prior_key, prior_pose, noise)
|
||||||
|
# AC-6 / Invariant 11a: do NOT advance ``_last_added_ts_ns`` —
|
||||||
|
# this is a pre-takeoff seed, not a measurement; the first
|
||||||
|
# subsequent ``add_*`` call still sees the unguarded baseline.
|
||||||
|
ts_ns = time.monotonic_ns()
|
||||||
|
try:
|
||||||
|
handle.add_factor(factor)
|
||||||
|
self._values.insert(prior_key, prior_pose)
|
||||||
|
timestamps = _make_timestamp_map([prior_key], ts_ns)
|
||||||
|
handle.update(self._graph, self._values, timestamps)
|
||||||
|
except (EstimatorDegradedError, EstimatorFatalError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._log.error(
|
||||||
|
"c5.state.set_takeoff_origin_failed",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.state.set_takeoff_origin_failed",
|
||||||
|
"kv": {"error": str(exc)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise EstimatorDegradedError(f"set_takeoff_origin failed: {exc}") from exc
|
||||||
|
|
||||||
|
self._reset_staging()
|
||||||
|
self._record_committed_pose_key(prior_key)
|
||||||
|
self._takeoff_origin_set = (origin, sigma_horiz_m, sigma_vert_m)
|
||||||
|
self._origin_source = "manifest"
|
||||||
|
self._emit_cold_start_origin_set_fdr(
|
||||||
|
source="manifest",
|
||||||
|
origin=origin,
|
||||||
|
sigma_horiz_m=sigma_horiz_m,
|
||||||
|
sigma_vert_m=sigma_vert_m,
|
||||||
|
)
|
||||||
|
self._log.info(
|
||||||
|
"c5.cold_start_origin.set",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.cold_start_origin.set",
|
||||||
|
"kv": {
|
||||||
|
"source": "manifest",
|
||||||
|
"lat_deg": origin.lat_deg,
|
||||||
|
"lon_deg": origin.lon_deg,
|
||||||
|
"alt_m": origin.alt_m,
|
||||||
|
"sigma_horiz_m": sigma_horiz_m,
|
||||||
|
"sigma_vert_m": sigma_vert_m,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_takeoff_origin_args(
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
if not (
|
||||||
|
math.isfinite(origin.lat_deg)
|
||||||
|
and math.isfinite(origin.lon_deg)
|
||||||
|
and math.isfinite(origin.alt_m)
|
||||||
|
):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: non-finite component in origin {origin!r}"
|
||||||
|
)
|
||||||
|
if not (-90.0 <= origin.lat_deg <= 90.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: latitude {origin.lat_deg} outside WGS-84 [-90, 90]"
|
||||||
|
)
|
||||||
|
if not (-180.0 <= origin.lon_deg <= 180.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: longitude {origin.lon_deg} outside WGS-84 [-180, 180]"
|
||||||
|
)
|
||||||
|
if not (math.isfinite(sigma_horiz_m) and sigma_horiz_m > 0.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: sigma_horiz_m must be positive finite; got {sigma_horiz_m}"
|
||||||
|
)
|
||||||
|
if not (math.isfinite(sigma_vert_m) and sigma_vert_m > 0.0):
|
||||||
|
raise StateEstimatorConfigError(
|
||||||
|
f"set_takeoff_origin: sigma_vert_m must be positive finite; got {sigma_vert_m}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close_cold_start_window(self) -> None:
|
||||||
|
"""Mark the cold-start window closed on the first ``add_*`` call.
|
||||||
|
|
||||||
|
Idempotent — only the first invocation flips the flag and
|
||||||
|
emits the legacy-fallback FDR record (AC-13). Subsequent
|
||||||
|
calls are no-ops.
|
||||||
|
"""
|
||||||
|
if self._cold_start_window_closed:
|
||||||
|
return
|
||||||
|
self._cold_start_window_closed = True
|
||||||
|
if self._origin_source is None:
|
||||||
|
self._origin_source = "fc_ekf"
|
||||||
|
origin = self._enu_origin if self._enu_origin is not None else _DEFAULT_ENU_ORIGIN
|
||||||
|
self._emit_cold_start_origin_set_fdr(
|
||||||
|
source="fc_ekf",
|
||||||
|
origin=origin,
|
||||||
|
sigma_horiz_m=self._block.default_takeoff_origin_sigma_horiz_m,
|
||||||
|
sigma_vert_m=self._block.default_takeoff_origin_sigma_vert_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _emit_cold_start_origin_set_fdr(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source: Literal["manifest", "fc_ekf"],
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
if self._fdr_client is None:
|
||||||
|
return
|
||||||
|
record = FdrRecord(
|
||||||
|
schema_version=1,
|
||||||
|
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||||
|
producer_id="c5_state",
|
||||||
|
kind="c5.cold_start_origin.set",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
"lat_deg": origin.lat_deg,
|
||||||
|
"lon_deg": origin.lon_deg,
|
||||||
|
"alt_m": origin.alt_m,
|
||||||
|
"sigma_horiz_m": sigma_horiz_m,
|
||||||
|
"sigma_vert_m": sigma_vert_m,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._fdr_client.enqueue(record)
|
||||||
|
except Exception as exc:
|
||||||
|
self._log.warning(
|
||||||
|
"c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||||
|
extra={
|
||||||
|
"kind": "c5.cold_start_origin.set_fdr_enqueue_failed",
|
||||||
|
"kv": {"source": source, "error": repr(exc)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def notify_gps_sample(self, sample: GpsSample, now_ns: int | None = None) -> str | None:
|
||||||
|
"""Bounded-delta gate dispatch for an inbound FC GPS sample (AZ-490).
|
||||||
|
|
||||||
|
Looks up the smoother's current latlon (best-effort — returns
|
||||||
|
``None`` if no committed pose is available yet) and delegates
|
||||||
|
to :meth:`SourceLabelStateMachine.process_gps_sample`. The
|
||||||
|
return value mirrors that method's tri-state result
|
||||||
|
(``"BOUNDED_DELTA_SOFT"`` / ``"REJECT"`` / ``None``); the
|
||||||
|
composition root uses it to decide whether to enqueue the
|
||||||
|
sample as a soft factor.
|
||||||
|
"""
|
||||||
|
machine = self._source_label_machine
|
||||||
|
if not isinstance(machine, SourceLabelStateMachine):
|
||||||
|
return None
|
||||||
|
smoother_latlon: LatLonAlt | None
|
||||||
|
if self._last_committed_pose_key is None:
|
||||||
|
smoother_latlon = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
pose = self._pose_at_key(self._last_committed_pose_key)
|
||||||
|
smoother_latlon = self._enu_pose_to_wgs84(pose)
|
||||||
|
except EstimatorFatalError:
|
||||||
|
smoother_latlon = None
|
||||||
|
return machine.process_gps_sample(
|
||||||
|
sample.position_wgs84,
|
||||||
|
smoother_estimate=smoother_latlon,
|
||||||
|
now_ns=now_ns,
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# AZ-383: factor-add bodies.
|
# AZ-383: factor-add bodies.
|
||||||
|
|
||||||
@@ -405,6 +652,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
single ``handle.update()`` per AC-7.
|
single ``handle.update()`` per AC-7.
|
||||||
"""
|
"""
|
||||||
handle = self._require_handle()
|
handle = self._require_handle()
|
||||||
|
self._close_cold_start_window()
|
||||||
ts_ns = _datetime_to_ns(vio.timestamp)
|
ts_ns = _datetime_to_ns(vio.timestamp)
|
||||||
self._guard_timestamp(ts_ns, source="vio")
|
self._guard_timestamp(ts_ns, source="vio")
|
||||||
|
|
||||||
@@ -476,6 +724,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
gate (AZ-385) and ``last_anchor_age_ms`` see a recent anchor.
|
gate (AZ-385) and ``last_anchor_age_ms`` see a recent anchor.
|
||||||
"""
|
"""
|
||||||
handle = self._require_handle()
|
handle = self._require_handle()
|
||||||
|
self._close_cold_start_window()
|
||||||
ts_ns = int(pose.emitted_at)
|
ts_ns = int(pose.emitted_at)
|
||||||
self._guard_timestamp(ts_ns, source="pose_anchor")
|
self._guard_timestamp(ts_ns, source="pose_anchor")
|
||||||
|
|
||||||
@@ -560,6 +809,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
|
|||||||
owns the per-window factor add.
|
owns the per-window factor add.
|
||||||
"""
|
"""
|
||||||
handle = self._require_handle()
|
handle = self._require_handle()
|
||||||
|
self._close_cold_start_window()
|
||||||
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
self._guard_timestamp(imu_window.ts_end_ns, source="imu_window")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from gps_denied_onboard._types.nav import ImuWindow
|
from gps_denied_onboard._types.nav import ImuWindow
|
||||||
from gps_denied_onboard._types.pose import PoseEstimate
|
from gps_denied_onboard._types.pose import LatLonAlt, PoseEstimate
|
||||||
from gps_denied_onboard._types.state import (
|
from gps_denied_onboard._types.state import (
|
||||||
EstimatorHealth,
|
EstimatorHealth,
|
||||||
EstimatorOutput,
|
EstimatorOutput,
|
||||||
@@ -41,6 +41,33 @@ class StateEstimator(Protocol):
|
|||||||
impls must implement every method.
|
impls must implement every method.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def set_takeoff_origin(
|
||||||
|
self,
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
"""Seed the cold-start prior with an operator-supplied origin (AZ-490 / ADR-010).
|
||||||
|
|
||||||
|
Pre-takeoff entrypoint. Called by the composition root during
|
||||||
|
F2 (Takeoff load) when the C10 ManifestVerifier reports a
|
||||||
|
valid ``flight.takeoff_origin``. With this prior set, the
|
||||||
|
FC-EKF first-frame cold-start path becomes the secondary
|
||||||
|
fallback (Invariant 11).
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
|
||||||
|
* Idempotent if called twice with byte-identical args (no-op).
|
||||||
|
* Raises :class:`StateEstimatorConfigError` if called twice
|
||||||
|
with different args, on negative / non-finite sigmas, or on
|
||||||
|
a ``LatLonAlt`` outside WGS-84 bounds.
|
||||||
|
* Raises :class:`EstimatorAlreadyStartedError` if called
|
||||||
|
AFTER the first ``add_vio`` / ``add_pose_anchor`` /
|
||||||
|
``add_fc_imu`` (cold-start window closed).
|
||||||
|
* Emits one ``c5.cold_start_origin.set`` FDR record with
|
||||||
|
``source="manifest"``.
|
||||||
|
"""
|
||||||
|
|
||||||
def add_vio(self, vio: VioOutput) -> None:
|
def add_vio(self, vio: VioOutput) -> None:
|
||||||
"""Add a VIO output as a relative-pose factor to the iSAM2 graph."""
|
"""Add a VIO output as a relative-pose factor to the iSAM2 graph."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,86 @@
|
|||||||
"""C6 Tile Cache & Vector Index component — Public API."""
|
"""C6 Tile Cache & Vector Index — Public API (AZ-303).
|
||||||
|
|
||||||
from gps_denied_onboard._types.tile import (
|
Per ``tile_store.md`` / ``tile_metadata_store.md`` / ``descriptor_index.md``
|
||||||
|
v1.0.0, the public surface consists of:
|
||||||
|
|
||||||
|
- Three Protocols: :class:`TileStore`, :class:`TileMetadataStore`,
|
||||||
|
:class:`DescriptorIndex`.
|
||||||
|
- DTOs: :class:`TileId`, :class:`TileMetadata`, :class:`TileMetadataPersistent`,
|
||||||
|
:class:`TileQualityMetadata`, :class:`Bbox`, :class:`SectorBoundary`,
|
||||||
|
:class:`HnswParams`, :class:`IndexMetadata`.
|
||||||
|
- Enums: :class:`TileSource`, :class:`FreshnessLabel`, :class:`VotingStatus`,
|
||||||
|
:class:`SectorClassification`.
|
||||||
|
- ABC: :class:`TilePixelHandle`.
|
||||||
|
- Errors: the :class:`TileCacheError` family + :class:`IndexBuildError`.
|
||||||
|
- Config block: :class:`C6TileCacheConfig` (registered on import).
|
||||||
|
|
||||||
|
Concrete impls (``PostgresFilesystemStore``, ``FaissDescriptorIndex``)
|
||||||
|
live in sibling modules and are imported lazily by the composition root
|
||||||
|
factories at ``runtime_root.storage_factory`` — Risk-2 mitigation: this
|
||||||
|
``__init__.py`` MUST NOT import any concrete impl module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache._tile_pixel_handle import (
|
||||||
|
TilePixelHandle,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||||
|
Bbox,
|
||||||
|
FreshnessLabel,
|
||||||
|
HnswParams,
|
||||||
|
IndexMetadata,
|
||||||
|
SectorBoundary,
|
||||||
SectorClassification,
|
SectorClassification,
|
||||||
Tile,
|
TileId,
|
||||||
|
TileMetadata,
|
||||||
|
TileMetadataPersistent,
|
||||||
TileQualityMetadata,
|
TileQualityMetadata,
|
||||||
TileRecord,
|
TileSource,
|
||||||
|
VotingStatus,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.config import C6TileCacheConfig
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.errors import (
|
||||||
|
ContentHashMismatchError,
|
||||||
|
FreshnessRejectionError,
|
||||||
|
IndexBuildError,
|
||||||
|
IndexUnavailableError,
|
||||||
|
TileCacheError,
|
||||||
|
TileFsError,
|
||||||
|
TileMetadataError,
|
||||||
|
TileNotFoundError,
|
||||||
)
|
)
|
||||||
from gps_denied_onboard.components.c6_tile_cache.interface import (
|
from gps_denied_onboard.components.c6_tile_cache.interface import (
|
||||||
DescriptorIndex,
|
DescriptorIndex,
|
||||||
|
TileMetadataStore,
|
||||||
TileStore,
|
TileStore,
|
||||||
)
|
)
|
||||||
|
from gps_denied_onboard.config.schema import register_component_block
|
||||||
|
|
||||||
|
register_component_block("c6_tile_cache", C6TileCacheConfig)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Bbox",
|
||||||
|
"C6TileCacheConfig",
|
||||||
|
"ContentHashMismatchError",
|
||||||
"DescriptorIndex",
|
"DescriptorIndex",
|
||||||
|
"FreshnessLabel",
|
||||||
|
"FreshnessRejectionError",
|
||||||
|
"HnswParams",
|
||||||
|
"IndexBuildError",
|
||||||
|
"IndexMetadata",
|
||||||
|
"IndexUnavailableError",
|
||||||
|
"SectorBoundary",
|
||||||
"SectorClassification",
|
"SectorClassification",
|
||||||
"Tile",
|
"TileCacheError",
|
||||||
|
"TileFsError",
|
||||||
|
"TileId",
|
||||||
|
"TileMetadata",
|
||||||
|
"TileMetadataError",
|
||||||
|
"TileMetadataPersistent",
|
||||||
|
"TileMetadataStore",
|
||||||
|
"TileNotFoundError",
|
||||||
|
"TilePixelHandle",
|
||||||
"TileQualityMetadata",
|
"TileQualityMetadata",
|
||||||
"TileRecord",
|
"TileSource",
|
||||||
"TileStore",
|
"TileStore",
|
||||||
|
"VotingStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""``TilePixelHandle`` ABC for read-only mmap-backed JPEG access.
|
||||||
|
|
||||||
|
Concrete impls (e.g., ``PostgresFilesystemStore``'s internal
|
||||||
|
``_FilesystemTilePixelHandle``) subclass and provide an
|
||||||
|
``__enter__`` that returns a read-only :class:`memoryview` over the
|
||||||
|
mmap. Consumers use the handle in a ``with`` block; the underlying
|
||||||
|
mmap is unmapped on ``__exit__``.
|
||||||
|
|
||||||
|
Invariant I-4 of ``tile_store.md`` makes the read-only guarantee a
|
||||||
|
contract obligation: a writer that mutates through the memoryview
|
||||||
|
is a Critical Reliability finding at code-review time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
|
||||||
|
|
||||||
|
class TilePixelHandle(ABC):
|
||||||
|
"""Opaque read-only view of a tile's JPEG bytes.
|
||||||
|
|
||||||
|
Lifetime is bounded by the caller's ``with`` block. The handle
|
||||||
|
MUST NOT outlive its ``__exit__``; consumers MUST NOT cache the
|
||||||
|
:class:`memoryview` past the block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def filesystem_path(self) -> Path:
|
||||||
|
"""Absolute path to the JPEG file backing this handle.
|
||||||
|
|
||||||
|
Used only by C12 operator tooling (post-flight inspection)
|
||||||
|
and the C11 ``TileUploader`` post-landing copy. In-flight
|
||||||
|
consumers MUST NOT open a second handle to the same path;
|
||||||
|
they MUST use this :class:`TilePixelHandle`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def __enter__(self) -> memoryview:
|
||||||
|
"""Return a read-only :class:`memoryview` over the JPEG bytes."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: type[BaseException] | None,
|
||||||
|
exc_val: BaseException | None,
|
||||||
|
exc_tb: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
"""Release the mmap. Always called by the ``with`` block."""
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""C6 tile cache DTOs + enums (AZ-303).
|
||||||
|
|
||||||
|
This module is the single source of truth for the data shapes the three
|
||||||
|
``c6_tile_cache`` Protocols (``TileStore``, ``TileMetadataStore``,
|
||||||
|
``DescriptorIndex``) exchange with consumers. Every DTO is
|
||||||
|
``@dataclass(frozen=True)`` per PEP 557; every enum is ``str, Enum``
|
||||||
|
so JSON / Postgres / FDR serialisation is trivial.
|
||||||
|
|
||||||
|
The contract files at ``_docs/02_document/contracts/c6_tile_cache/``
|
||||||
|
are the authoritative human-readable shape — this module mirrors them
|
||||||
|
1:1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bbox",
|
||||||
|
"FreshnessLabel",
|
||||||
|
"HnswParams",
|
||||||
|
"IndexMetadata",
|
||||||
|
"SectorBoundary",
|
||||||
|
"SectorClassification",
|
||||||
|
"TileId",
|
||||||
|
"TileMetadata",
|
||||||
|
"TileMetadataPersistent",
|
||||||
|
"TileQualityMetadata",
|
||||||
|
"TileSource",
|
||||||
|
"VotingStatus",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_ZOOM_LEVEL = 21 # satellite-provider legal range upper bound
|
||||||
|
|
||||||
|
|
||||||
|
class TileSource(str, Enum):
|
||||||
|
"""Where a tile originated.
|
||||||
|
|
||||||
|
``GOOGLEMAPS``: pre-flight download via ``satellite-provider``.
|
||||||
|
``ONBOARD_INGEST``: produced mid-flight by C5 orthorectification
|
||||||
|
(F4 path) and uploaded post-landing by C11.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GOOGLEMAPS = "googlemaps"
|
||||||
|
ONBOARD_INGEST = "onboard_ingest"
|
||||||
|
|
||||||
|
|
||||||
|
class FreshnessLabel(str, Enum):
|
||||||
|
"""C6 freshness gate output.
|
||||||
|
|
||||||
|
See ``tile_metadata_store.md`` Invariants I-2 / I-3 for the
|
||||||
|
transitions; this enum only exposes the four legal states.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FRESH = "fresh"
|
||||||
|
STALE_ACTIVE_CONFLICT = "stale_active_conflict"
|
||||||
|
STALE_REAR = "stale_rear"
|
||||||
|
DOWNGRADED = "downgraded"
|
||||||
|
|
||||||
|
|
||||||
|
class VotingStatus(str, Enum):
|
||||||
|
"""C6 voting state for an onboard-ingested tile.
|
||||||
|
|
||||||
|
Forward-only transitions per Invariant I-8 of
|
||||||
|
``tile_metadata_store.md``: ``PENDING → TRUSTED``,
|
||||||
|
``PENDING → REJECTED``, ``TRUSTED → REJECTED``. The
|
||||||
|
impl (NOT this task) enforces the transition table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
TRUSTED = "trusted"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class SectorClassification(str, Enum):
|
||||||
|
"""Operator-set classification of a geographic sector.
|
||||||
|
|
||||||
|
Drives the C6 freshness gate. Set pre-flight by C12 against the
|
||||||
|
``sector_boundaries`` table; the metadata store reads sector rows
|
||||||
|
at insert-time only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACTIVE_CONFLICT = "active_conflict"
|
||||||
|
STABLE_REAR = "stable_rear"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TileId:
|
||||||
|
"""Spatial identity of a Web-Mercator tile.
|
||||||
|
|
||||||
|
``(zoom_level, lat, lon)`` is the composite identity. ``(x, y)``
|
||||||
|
integer tile coordinates are derived by the same function
|
||||||
|
``satellite-provider`` uses (Invariant I-1 of ``tile_store.md``);
|
||||||
|
this DTO carries the WGS84 form because it is the consumer-facing
|
||||||
|
shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
zoom_level: int
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0 <= self.zoom_level <= _MAX_ZOOM_LEVEL:
|
||||||
|
raise ValueError(
|
||||||
|
f"TileId.zoom_level must be in [0, {_MAX_ZOOM_LEVEL}]; "
|
||||||
|
f"got {self.zoom_level}"
|
||||||
|
)
|
||||||
|
if not -90.0 <= self.lat <= 90.0:
|
||||||
|
raise ValueError(
|
||||||
|
f"TileId.lat must be in [-90.0, 90.0]; got {self.lat}"
|
||||||
|
)
|
||||||
|
if not -180.0 <= self.lon <= 180.0:
|
||||||
|
raise ValueError(
|
||||||
|
f"TileId.lon must be in [-180.0, 180.0]; got {self.lon}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Bbox:
|
||||||
|
"""Axis-aligned WGS84 bounding box, inclusive on min, exclusive on max."""
|
||||||
|
|
||||||
|
min_lat: float
|
||||||
|
min_lon: float
|
||||||
|
max_lat: float
|
||||||
|
max_lon: float
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.min_lat >= self.max_lat:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bbox.min_lat ({self.min_lat}) must be < max_lat "
|
||||||
|
f"({self.max_lat})"
|
||||||
|
)
|
||||||
|
if self.min_lon >= self.max_lon:
|
||||||
|
raise ValueError(
|
||||||
|
f"Bbox.min_lon ({self.min_lon}) must be < max_lon "
|
||||||
|
f"({self.max_lon})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TileQualityMetadata:
|
||||||
|
"""Quality metadata attached to an onboard-ingested tile.
|
||||||
|
|
||||||
|
Set at write-time by C5; consumed by C6's freshness gate (insert
|
||||||
|
path) and by C11's voting-status updater (post-landing). Field
|
||||||
|
semantics are documented in the D-PROJ-2 ingest contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
estimator_label: str
|
||||||
|
covariance_2x2: tuple[tuple[float, float], tuple[float, float]]
|
||||||
|
last_anchor_age_ms: int
|
||||||
|
mre_px: float
|
||||||
|
imu_bias_norm: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TileMetadata:
|
||||||
|
"""Per-row tile metadata as understood by both ``TileStore`` and
|
||||||
|
``TileMetadataStore``.
|
||||||
|
|
||||||
|
``flight_id`` / ``companion_id`` / ``quality_metadata`` are set
|
||||||
|
only for :attr:`TileSource.ONBOARD_INGEST`; for
|
||||||
|
:attr:`TileSource.GOOGLEMAPS` they are ``None``. ``voting_status``
|
||||||
|
defaults to :attr:`VotingStatus.PENDING` for onboard ingest and
|
||||||
|
:attr:`VotingStatus.TRUSTED` for googlemaps (the impl applies the
|
||||||
|
default; this DTO does not).
|
||||||
|
"""
|
||||||
|
|
||||||
|
tile_id: TileId
|
||||||
|
tile_size_meters: float
|
||||||
|
tile_size_pixels: int
|
||||||
|
capture_timestamp: datetime
|
||||||
|
source: TileSource
|
||||||
|
content_sha256_hex: str
|
||||||
|
freshness_label: FreshnessLabel
|
||||||
|
flight_id: str | None
|
||||||
|
companion_id: str | None
|
||||||
|
quality_metadata: TileQualityMetadata | None
|
||||||
|
voting_status: VotingStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TileMetadataPersistent:
|
||||||
|
"""In-process view of LRU + disk-budget bookkeeping.
|
||||||
|
|
||||||
|
Returned ONLY by ``TileMetadataStore.lru_candidates`` and
|
||||||
|
constructed inside the impl when responding to
|
||||||
|
``record_lru_access`` / ``total_disk_bytes``. Consumers reading
|
||||||
|
by ``tile_id`` / by ``bbox`` get the plain :class:`TileMetadata`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata: TileMetadata
|
||||||
|
accessed_at: datetime
|
||||||
|
uploaded_at: datetime | None
|
||||||
|
disk_bytes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SectorBoundary:
|
||||||
|
"""A single operator-defined sector polygon (axis-aligned bbox today)."""
|
||||||
|
|
||||||
|
bbox: Bbox
|
||||||
|
classification: SectorClassification
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HnswParams:
|
||||||
|
"""HNSW build hyperparameters.
|
||||||
|
|
||||||
|
Defaults track the FAISS-team baseline (``HNSW32`` + M=32 +
|
||||||
|
efConstruction=200 + efSearch=64); overriding any value is a
|
||||||
|
research-time concern not exercised in production.
|
||||||
|
"""
|
||||||
|
|
||||||
|
m: int = 32
|
||||||
|
ef_construction: int = 200
|
||||||
|
ef_search: int = 64
|
||||||
|
metric: str = "L2"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IndexMetadata:
|
||||||
|
"""Sidecar metadata block written next to the ``.index`` file.
|
||||||
|
|
||||||
|
Read by ``DescriptorIndex.index_metadata()`` at runtime; produced
|
||||||
|
by C10's pre-flight ``rebuild_from_descriptors`` together with the
|
||||||
|
AZ-280 ``.sha256`` sidecar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
descriptor_dim: int
|
||||||
|
n_vectors: int
|
||||||
|
backbone_label: str
|
||||||
|
backbone_sha256_hex: str
|
||||||
|
built_at: datetime
|
||||||
|
hnsw_params: HnswParams
|
||||||
|
sidecar_sha256_hex: str
|
||||||
|
file_path: Path
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""C6 tile cache config block (AZ-303).
|
||||||
|
|
||||||
|
Registered into ``config.components['c6_tile_cache']`` by the package
|
||||||
|
``__init__.py``. The composition-root factories
|
||||||
|
(``build_tile_store`` / ``build_tile_metadata_store`` /
|
||||||
|
``build_descriptor_index``) read this block to select the runtime and
|
||||||
|
locate the persistent root.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from gps_denied_onboard.config.schema import ConfigError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"C6TileCacheConfig",
|
||||||
|
"KNOWN_DESCRIPTOR_INDEX_RUNTIMES",
|
||||||
|
"KNOWN_METADATA_RUNTIMES",
|
||||||
|
"KNOWN_TILE_STORE_RUNTIMES",
|
||||||
|
]
|
||||||
|
|
||||||
|
KNOWN_TILE_STORE_RUNTIMES: Final[frozenset[str]] = frozenset({"postgres_filesystem"})
|
||||||
|
KNOWN_METADATA_RUNTIMES: Final[frozenset[str]] = frozenset({"postgres_filesystem"})
|
||||||
|
KNOWN_DESCRIPTOR_INDEX_RUNTIMES: Final[frozenset[str]] = frozenset({"faiss_hnsw"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class C6TileCacheConfig:
|
||||||
|
"""Per-component config for C6 tile cache.
|
||||||
|
|
||||||
|
``store_runtime`` / ``metadata_runtime`` are currently both
|
||||||
|
``"postgres_filesystem"`` (single concrete impl); the field
|
||||||
|
exists so a future SQLite Tier-0 dev runtime can be added
|
||||||
|
without rippling the contract.
|
||||||
|
|
||||||
|
``descriptor_index_runtime`` selects the vector index strategy
|
||||||
|
(today only ``"faiss_hnsw"``; gated by ``BUILD_FAISS_INDEX``).
|
||||||
|
|
||||||
|
``root_dir`` is the filesystem root for tile JPEGs +
|
||||||
|
``.index`` sidecars; layout is byte-identical to
|
||||||
|
``satellite-provider`` (Invariant I-1 of ``tile_store.md``).
|
||||||
|
|
||||||
|
``postgres_dsn`` is the PostgreSQL connection string the
|
||||||
|
metadata-store impl uses (e.g. ``postgresql://...``); empty
|
||||||
|
string means "use the runtime ``db_url`` from ``RuntimeConfig``"
|
||||||
|
(the impl will resolve at build time).
|
||||||
|
|
||||||
|
``lru_eviction_threshold_bytes`` is the disk-budget high-water
|
||||||
|
mark the cache-budget enforcer (separate task) uses to trigger
|
||||||
|
LRU eviction; default 10 GiB per the C6 description.
|
||||||
|
"""
|
||||||
|
|
||||||
|
store_runtime: str = "postgres_filesystem"
|
||||||
|
metadata_runtime: str = "postgres_filesystem"
|
||||||
|
descriptor_index_runtime: str = "faiss_hnsw"
|
||||||
|
root_dir: str = "/var/lib/gps-denied/tiles"
|
||||||
|
postgres_dsn: str = ""
|
||||||
|
lru_eviction_threshold_bytes: int = 10 * 1024**3
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.store_runtime not in KNOWN_TILE_STORE_RUNTIMES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C6TileCacheConfig.store_runtime={self.store_runtime!r} not in "
|
||||||
|
f"{sorted(KNOWN_TILE_STORE_RUNTIMES)}"
|
||||||
|
)
|
||||||
|
if self.metadata_runtime not in KNOWN_METADATA_RUNTIMES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C6TileCacheConfig.metadata_runtime={self.metadata_runtime!r} not in "
|
||||||
|
f"{sorted(KNOWN_METADATA_RUNTIMES)}"
|
||||||
|
)
|
||||||
|
if self.descriptor_index_runtime not in KNOWN_DESCRIPTOR_INDEX_RUNTIMES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C6TileCacheConfig.descriptor_index_runtime="
|
||||||
|
f"{self.descriptor_index_runtime!r} not in "
|
||||||
|
f"{sorted(KNOWN_DESCRIPTOR_INDEX_RUNTIMES)}"
|
||||||
|
)
|
||||||
|
if not self.root_dir:
|
||||||
|
raise ConfigError("C6TileCacheConfig.root_dir must be non-empty")
|
||||||
|
if self.lru_eviction_threshold_bytes <= 0:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C6TileCacheConfig.lru_eviction_threshold_bytes must be > 0; "
|
||||||
|
f"got {self.lru_eviction_threshold_bytes}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""C6 tile cache runtime error taxonomy (AZ-303).
|
||||||
|
|
||||||
|
All read-side errors raised by ``TileStore`` / ``TileMetadataStore`` /
|
||||||
|
``DescriptorIndex`` are subclasses of :class:`TileCacheError`. Consumers
|
||||||
|
catch the family; implementations rewrap third-party exceptions
|
||||||
|
(psycopg, FAISS C++, OS errors) into one of these types.
|
||||||
|
|
||||||
|
:class:`IndexBuildError` is intentionally NOT in the :class:`TileCacheError`
|
||||||
|
family — it is raised only by the offline ``rebuild_from_descriptors``
|
||||||
|
path (C10 pre-flight provisioning) and has different fault semantics
|
||||||
|
than the in-flight read envelope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ContentHashMismatchError",
|
||||||
|
"FreshnessRejectionError",
|
||||||
|
"IndexBuildError",
|
||||||
|
"IndexUnavailableError",
|
||||||
|
"TileCacheError",
|
||||||
|
"TileFsError",
|
||||||
|
"TileMetadataError",
|
||||||
|
"TileNotFoundError",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TileCacheError(Exception):
|
||||||
|
"""Base class for the C6 read-side error family.
|
||||||
|
|
||||||
|
Consumers MUST be able to catch the family with a single
|
||||||
|
``except TileCacheError:`` and recover or surface to FDR.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TileNotFoundError(TileCacheError):
|
||||||
|
"""A tile lookup did not find the requested ``TileId`` on disk.
|
||||||
|
|
||||||
|
Distinct from :class:`TileMetadataError`: the metadata row is also
|
||||||
|
absent, so the cache is in a consistent (just-empty) state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TileFsError(TileCacheError):
|
||||||
|
"""An OS-level filesystem error escaped from a Protocol method.
|
||||||
|
|
||||||
|
Always wraps the originating ``OSError`` via ``__cause__``. The
|
||||||
|
impl MUST rewrap; raw ``OSError`` MUST NOT escape the Protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TileMetadataError(TileCacheError):
|
||||||
|
"""The metadata store violated a consistency invariant.
|
||||||
|
|
||||||
|
Includes: row present but JPEG missing (or vice-versa), duplicate
|
||||||
|
composite-key insert, backward voting-status transition. See
|
||||||
|
``tile_metadata_store.md`` Invariants I-1, I-8 for the full list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ContentHashMismatchError(TileCacheError):
|
||||||
|
"""``sha256(tile_blob) != metadata.content_sha256_hex`` on ``write_tile``.
|
||||||
|
|
||||||
|
Bound to the cache-poisoning safety budget (D-C10-3 / AC-NEW-7);
|
||||||
|
the impl MUST NOT silently retry — surface to the caller (and FDR)
|
||||||
|
so the operator can investigate the source feed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FreshnessRejectionError(TileCacheError):
|
||||||
|
"""The C6 freshness gate rejected an insert.
|
||||||
|
|
||||||
|
Raised when the tile's ``(lat, lon)`` falls in an ``ACTIVE_CONFLICT``
|
||||||
|
sector AND ``capture_timestamp < now() - active_conflict_max_age``.
|
||||||
|
See ``tile_metadata_store.md`` Invariant I-2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IndexUnavailableError(TileCacheError):
|
||||||
|
"""The descriptor index could not satisfy a read.
|
||||||
|
|
||||||
|
Includes: mmap handle invalid, ``.index`` file missing, sidecar
|
||||||
|
``.sha256`` mismatched, query vector dimension does not match
|
||||||
|
``descriptor_dim()``. See ``descriptor_index.md`` Invariants
|
||||||
|
I-3 / I-6.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class IndexBuildError(Exception):
|
||||||
|
"""Offline index build failed.
|
||||||
|
|
||||||
|
Intentionally NOT a :class:`TileCacheError` subclass — the build
|
||||||
|
path is the C10 pre-flight provisioning envelope, distinct from
|
||||||
|
the in-flight read envelope. C2 catches :class:`TileCacheError`;
|
||||||
|
C10 catches :class:`IndexBuildError`.
|
||||||
|
"""
|
||||||
@@ -1,32 +1,205 @@
|
|||||||
"""C6 `TileStore` + `DescriptorIndex` Protocols.
|
"""C6 tile cache Protocols (AZ-303).
|
||||||
|
|
||||||
Concrete impl: `PostgresFilesystemStore` (Postgres mirror + filesystem mmap +
|
Three ``runtime_checkable`` ``typing.Protocol`` declarations:
|
||||||
FAISS HNSW). See `_docs/02_document/components/08_c6_tile_cache/`.
|
|
||||||
|
- :class:`TileStore` — filesystem-resident JPEG body I/O.
|
||||||
|
- :class:`TileMetadataStore` — Postgres-backed spatial + LRU + voting bookkeeping.
|
||||||
|
- :class:`DescriptorIndex` — per-flight FAISS HNSW vector index.
|
||||||
|
|
||||||
|
The contract files at ``_docs/02_document/contracts/c6_tile_cache/``
|
||||||
|
are the authoritative human-readable shape; this module mirrors them
|
||||||
|
1:1. Concrete impls live in sibling modules
|
||||||
|
(``postgres_filesystem_store``, ``faiss_descriptor_index``); the
|
||||||
|
composition root selects between them via
|
||||||
|
``runtime_root.storage_factory``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from datetime import datetime
|
||||||
from typing import Protocol
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||||
|
|
||||||
from gps_denied_onboard._types.tile import Tile, TileRecord
|
from gps_denied_onboard.components.c6_tile_cache._tile_pixel_handle import (
|
||||||
|
TilePixelHandle,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache._types import (
|
||||||
|
Bbox,
|
||||||
|
HnswParams,
|
||||||
|
IndexMetadata,
|
||||||
|
TileId,
|
||||||
|
TileMetadata,
|
||||||
|
TileMetadataPersistent,
|
||||||
|
TileSource,
|
||||||
|
VotingStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DescriptorIndex",
|
||||||
|
"TileMetadataStore",
|
||||||
|
"TileStore",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class TileStore(Protocol):
|
class TileStore(Protocol):
|
||||||
"""Tile metadata + body store (mirrors satellite-provider; cached locally)."""
|
"""JPEG body store. See ``tile_store.md`` v1.0.0.
|
||||||
|
|
||||||
def get(self, tile_id: str) -> Tile | None: ...
|
The four-method surface is intentionally minimal: read, write,
|
||||||
|
exists, delete. Spatial queries live on :class:`TileMetadataStore`;
|
||||||
|
descriptor lookups live on :class:`DescriptorIndex`.
|
||||||
|
"""
|
||||||
|
|
||||||
def query_by_lat_lon(
|
def read_tile_pixels(self, tile_id: TileId) -> TilePixelHandle:
|
||||||
self, lat: float, lon: float, zoom: int, radius_m: float
|
"""Return a read-only mmap handle to the tile's JPEG bytes.
|
||||||
) -> Iterable[TileRecord]: ...
|
|
||||||
|
|
||||||
def put(self, record: TileRecord) -> None: ...
|
Raises :class:`TileNotFoundError` if the tile is absent;
|
||||||
|
:class:`TileMetadataError` if the row exists but the JPEG file
|
||||||
|
is missing (or vice-versa — Invariant I-8).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def write_tile(self, tile_blob: bytes, metadata: TileMetadata) -> None:
|
||||||
|
"""Atomically persist a JPEG body + its sidecar + metadata row.
|
||||||
|
|
||||||
|
Raises :class:`ContentHashMismatchError` if
|
||||||
|
``sha256(tile_blob) != metadata.content_sha256_hex``;
|
||||||
|
:class:`FreshnessRejectionError` if the C6 freshness gate
|
||||||
|
rejects the insert; :class:`TileFsError` /
|
||||||
|
:class:`TileMetadataError` on infrastructure failure.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def tile_exists(self, tile_id: TileId) -> bool:
|
||||||
|
"""Cheap existence check (page-cache lookup). Does not load bytes."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_tile(self, tile_id: TileId) -> bool:
|
||||||
|
"""Remove the JPEG + sidecar; return ``True`` if a file was removed.
|
||||||
|
|
||||||
|
Returns ``False`` (no exception) if the tile was already
|
||||||
|
missing — the LRU evictor relies on this no-error path
|
||||||
|
(Invariant I-6).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TileMetadataStore(Protocol):
|
||||||
|
"""Postgres-backed metadata + spatial index + LRU bookkeeping.
|
||||||
|
|
||||||
|
See ``tile_metadata_store.md`` v1.0.0 for the nine-method surface
|
||||||
|
and the I-1..I-9 invariants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def query_by_bbox(
|
||||||
|
self,
|
||||||
|
bbox: Bbox,
|
||||||
|
zoom: int,
|
||||||
|
*,
|
||||||
|
voting_filter: VotingStatus | None = None,
|
||||||
|
source_filter: TileSource | None = None,
|
||||||
|
) -> list[TileMetadata]:
|
||||||
|
"""Spatial query, optionally filtered by voting status / source."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def insert_metadata(self, metadata: TileMetadata) -> None:
|
||||||
|
"""Insert a row; freshness gate runs atomically (Invariant I-7).
|
||||||
|
|
||||||
|
Raises :class:`FreshnessRejectionError` in
|
||||||
|
``ACTIVE_CONFLICT`` sectors when the tile is too old;
|
||||||
|
:class:`TileMetadataError` on composite-key collision.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def update_voting_status(
|
||||||
|
self, tile_id: TileId, status: VotingStatus
|
||||||
|
) -> None:
|
||||||
|
"""Forward-only state transition. Backward transitions raise."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def mark_uploaded(self, tile_id: TileId, uploaded_at: datetime) -> None:
|
||||||
|
"""Stamp ``uploaded_at`` so the row leaves ``pending_uploads``."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def pending_uploads(self) -> list[TileMetadata]:
|
||||||
|
"""Rows where ``source == ONBOARD_INGEST`` and ``uploaded_at IS NULL``.
|
||||||
|
|
||||||
|
Single source of truth for C11 ``TileUploader`` (Invariant I-9);
|
||||||
|
the uploader MUST NOT scan the filesystem.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def record_lru_access(
|
||||||
|
self, tile_id: TileId, accessed_at: datetime
|
||||||
|
) -> None:
|
||||||
|
"""Update ``accessed_at = max(current, supplied)`` (Invariant I-4)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def lru_candidates(
|
||||||
|
self, *, max_count: int
|
||||||
|
) -> list[TileMetadataPersistent]:
|
||||||
|
"""Return up to ``max_count`` oldest-``accessed_at``-first rows."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def total_disk_bytes(self) -> int:
|
||||||
|
"""``SUM(disk_bytes) WHERE voting_status != REJECTED`` (Invariant I-5)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_by_id(self, tile_id: TileId) -> TileMetadata | None:
|
||||||
|
"""Point lookup; returns ``None`` if absent (NOT raises)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class DescriptorIndex(Protocol):
|
class DescriptorIndex(Protocol):
|
||||||
"""Vector index over tile descriptors (FAISS HNSW concrete impl)."""
|
"""Per-flight FAISS HNSW vector index.
|
||||||
|
|
||||||
def add(self, tile_id: str, descriptor) -> None: ...
|
See ``descriptor_index.md`` v1.0.0 for the five-method surface
|
||||||
|
and the I-1..I-8 invariants. The single in-flight consumer is C2
|
||||||
|
VPR; the offline producer is C10 ``CacheProvisioner``.
|
||||||
|
"""
|
||||||
|
|
||||||
def search(self, descriptor, top_k: int) -> Iterable[tuple[str, float]]: ...
|
def search_topk(
|
||||||
|
self, query: "np.ndarray", k: int
|
||||||
|
) -> list[tuple[TileId, float]]:
|
||||||
|
"""Top-K nearest neighbour search.
|
||||||
|
|
||||||
|
``query`` must be ``(descriptor_dim,)`` ``float32`` C-contiguous;
|
||||||
|
dimension mismatch raises :class:`IndexUnavailableError`
|
||||||
|
(Invariant I-3). Returns ≤ k results, distance-ascending.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def descriptor_dim(self) -> int:
|
||||||
|
"""Indexed-vector dimension; fixed at build time (Invariant I-3)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def mmap_handle(self) -> Path:
|
||||||
|
"""Absolute path to the ``.index`` file.
|
||||||
|
|
||||||
|
Used by C12 operator post-flight inspection tooling; in-flight
|
||||||
|
callers MUST go through ``search_topk``.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def rebuild_from_descriptors(
|
||||||
|
self,
|
||||||
|
descriptors: "np.ndarray",
|
||||||
|
tile_ids: list[TileId],
|
||||||
|
hnsw_params: HnswParams,
|
||||||
|
) -> None:
|
||||||
|
"""Offline full-rebuild (C10 pre-flight; Invariant I-5 atomic).
|
||||||
|
|
||||||
|
Raises :class:`IndexBuildError` on dtype/shape mismatch or
|
||||||
|
underlying FAISS failure; :class:`TileFsError` on disk write
|
||||||
|
failure.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def index_metadata(self) -> IndexMetadata:
|
||||||
|
"""Sidecar metadata block (Invariant I-6 sidecar coherence)."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -1,6 +1,68 @@
|
|||||||
"""C7 Inference Runtime component — Public API."""
|
"""C7 Inference Runtime — Public API (AZ-297).
|
||||||
|
|
||||||
from gps_denied_onboard._types.manifests import EngineCacheEntry
|
Per ``inference_runtime_protocol.md`` v1.0.0 the public surface
|
||||||
|
consists of:
|
||||||
|
|
||||||
|
- :class:`InferenceRuntime` Protocol (6 methods).
|
||||||
|
- DTOs: :class:`BuildConfig`, :class:`EngineCacheEntry`,
|
||||||
|
:class:`EngineHandle` (opaque marker), :class:`OptimizationProfile`,
|
||||||
|
:class:`PrecisionMode` enum.
|
||||||
|
- :class:`ThermalState` re-exported from
|
||||||
|
:mod:`gps_denied_onboard._types.thermal` (its canonical home for
|
||||||
|
C4 forward-compatibility).
|
||||||
|
- Error family rooted at :class:`RuntimeError`; nine documented subtypes.
|
||||||
|
- Config block :class:`C7InferenceConfig` (registered on import).
|
||||||
|
|
||||||
|
Concrete strategies (``TensorrtRuntime``, ``OnnxTrtEpRuntime``,
|
||||||
|
``PytorchFp16Runtime``) live in sibling modules and are imported
|
||||||
|
lazily by :mod:`gps_denied_onboard.runtime_root.inference_factory` —
|
||||||
|
Risk-2 mitigation: this ``__init__.py`` MUST NOT import any concrete
|
||||||
|
strategy module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.inference import (
|
||||||
|
BuildConfig,
|
||||||
|
EngineCacheEntry,
|
||||||
|
EngineHandle,
|
||||||
|
OptimizationProfile,
|
||||||
|
PrecisionMode,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard._types.thermal import ThermalState
|
||||||
|
from gps_denied_onboard.components.c7_inference.config import C7InferenceConfig
|
||||||
|
from gps_denied_onboard.components.c7_inference.errors import (
|
||||||
|
CalibrationCacheError,
|
||||||
|
EngineBuildError,
|
||||||
|
EngineDeserializeError,
|
||||||
|
EngineHashMismatchError,
|
||||||
|
EngineSchemaMismatchError,
|
||||||
|
EngineSidecarMissingError,
|
||||||
|
InferenceError,
|
||||||
|
OutOfMemoryError,
|
||||||
|
RuntimeError,
|
||||||
|
TelemetryUnavailableError,
|
||||||
|
)
|
||||||
from gps_denied_onboard.components.c7_inference.interface import InferenceRuntime
|
from gps_denied_onboard.components.c7_inference.interface import InferenceRuntime
|
||||||
|
from gps_denied_onboard.config.schema import register_component_block
|
||||||
|
|
||||||
__all__ = ["EngineCacheEntry", "InferenceRuntime"]
|
register_component_block("c7_inference", C7InferenceConfig)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BuildConfig",
|
||||||
|
"C7InferenceConfig",
|
||||||
|
"CalibrationCacheError",
|
||||||
|
"EngineBuildError",
|
||||||
|
"EngineCacheEntry",
|
||||||
|
"EngineDeserializeError",
|
||||||
|
"EngineHandle",
|
||||||
|
"EngineHashMismatchError",
|
||||||
|
"EngineSchemaMismatchError",
|
||||||
|
"EngineSidecarMissingError",
|
||||||
|
"InferenceError",
|
||||||
|
"InferenceRuntime",
|
||||||
|
"OptimizationProfile",
|
||||||
|
"OutOfMemoryError",
|
||||||
|
"PrecisionMode",
|
||||||
|
"RuntimeError",
|
||||||
|
"TelemetryUnavailableError",
|
||||||
|
"ThermalState",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""C7 inference runtime config block (AZ-297).
|
||||||
|
|
||||||
|
Registered into ``config.components['c7_inference']`` by the package
|
||||||
|
``__init__.py``. The composition-root factory
|
||||||
|
:func:`gps_denied_onboard.runtime_root.inference_factory.build_inference_runtime`
|
||||||
|
reads this block to select the strategy and locate the engine cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from gps_denied_onboard.config.schema import ConfigError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"C7InferenceConfig",
|
||||||
|
"KNOWN_RUNTIMES",
|
||||||
|
]
|
||||||
|
|
||||||
|
KNOWN_RUNTIMES: Final[frozenset[str]] = frozenset(
|
||||||
|
{"tensorrt", "onnx_trt_ep", "pytorch_fp16"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class C7InferenceConfig:
|
||||||
|
"""Per-component config for C7 inference.
|
||||||
|
|
||||||
|
``runtime`` selects exactly one of the three strategies
|
||||||
|
(``tensorrt`` / ``onnx_trt_ep`` / ``pytorch_fp16``); the
|
||||||
|
composition-root factory respects compile-time ``BUILD_*`` gating
|
||||||
|
on top of this label (a runtime whose flag is OFF raises
|
||||||
|
:class:`RuntimeNotAvailableError` at composition time).
|
||||||
|
|
||||||
|
``thermal_poll_hz`` is the cadence at which AZ-302's
|
||||||
|
``ThermalStatePublisher`` polls ``tegrastats`` / ``jtop``;
|
||||||
|
default 1 Hz keeps the SDK telemetry source cool.
|
||||||
|
|
||||||
|
``engine_cache_dir`` is the filesystem root where compiled
|
||||||
|
``.engine`` binaries + ``.sha256`` sidecars live; the C10
|
||||||
|
pre-flight ``CacheProvisioner`` writes here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
runtime: str = "pytorch_fp16"
|
||||||
|
thermal_poll_hz: float = 1.0
|
||||||
|
engine_cache_dir: str = "/var/lib/gps-denied/engines"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.runtime not in KNOWN_RUNTIMES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C7InferenceConfig.runtime={self.runtime!r} not in "
|
||||||
|
f"{sorted(KNOWN_RUNTIMES)}"
|
||||||
|
)
|
||||||
|
if self.thermal_poll_hz <= 0:
|
||||||
|
raise ConfigError(
|
||||||
|
f"C7InferenceConfig.thermal_poll_hz must be > 0; "
|
||||||
|
f"got {self.thermal_poll_hz}"
|
||||||
|
)
|
||||||
|
if not self.engine_cache_dir:
|
||||||
|
raise ConfigError(
|
||||||
|
"C7InferenceConfig.engine_cache_dir must be non-empty"
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""C7 inference runtime error taxonomy (AZ-297).
|
||||||
|
|
||||||
|
All Protocol methods raise only members of :class:`RuntimeError` (the
|
||||||
|
C7-local family — NOT stdlib :class:`builtins.RuntimeError`).
|
||||||
|
Consumers catch the family with a single
|
||||||
|
``except c7_inference.errors.RuntimeError``; implementations rewrap
|
||||||
|
third-party library exceptions (TRT C++ exceptions, ORT internal
|
||||||
|
errors, PyTorch CUDA errors) into one of these types.
|
||||||
|
|
||||||
|
Two composition-time errors live OUTSIDE the family:
|
||||||
|
- :class:`gps_denied_onboard.runtime_root.errors.RuntimeNotAvailableError`
|
||||||
|
is raised by the factory when the requested ``BUILD_*`` is OFF.
|
||||||
|
- :class:`gps_denied_onboard.config.schema.ConfigError` is raised at
|
||||||
|
config-load time for unknown ``runtime`` labels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CalibrationCacheError",
|
||||||
|
"EngineBuildError",
|
||||||
|
"EngineDeserializeError",
|
||||||
|
"EngineHashMismatchError",
|
||||||
|
"EngineSchemaMismatchError",
|
||||||
|
"EngineSidecarMissingError",
|
||||||
|
"InferenceError",
|
||||||
|
"OutOfMemoryError",
|
||||||
|
"RuntimeError",
|
||||||
|
"TelemetryUnavailableError",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeError(Exception): # noqa: A001 - the contract names this RuntimeError
|
||||||
|
"""Base class for the C7 inference runtime error family.
|
||||||
|
|
||||||
|
Named ``RuntimeError`` per the contract (``inference_runtime_protocol.md``
|
||||||
|
Shape § Error hierarchy); this shadows the stdlib name only inside
|
||||||
|
``c7_inference.errors``. Consumers MUST import from this module by
|
||||||
|
fully-qualified name to avoid collision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class EngineBuildError(RuntimeError):
|
||||||
|
"""``compile_engine`` failed before producing a usable engine binary."""
|
||||||
|
|
||||||
|
|
||||||
|
class EngineDeserializeError(RuntimeError):
|
||||||
|
"""``deserialize_engine`` could not load the engine binary into memory."""
|
||||||
|
|
||||||
|
|
||||||
|
class EngineHashMismatchError(RuntimeError):
|
||||||
|
"""The sha256 of the engine file does not match
|
||||||
|
:attr:`EngineCacheEntry.sha256_hex` (D-C10-3 / cache poisoning gate)."""
|
||||||
|
|
||||||
|
|
||||||
|
class EngineSchemaMismatchError(RuntimeError):
|
||||||
|
"""The engine's IO schema (input / output tensor names + shapes) does
|
||||||
|
not match the runtime expectation (D-C10-7)."""
|
||||||
|
|
||||||
|
|
||||||
|
class EngineSidecarMissingError(RuntimeError):
|
||||||
|
"""The ``.sha256`` sidecar file expected next to the engine binary is
|
||||||
|
absent or unreadable."""
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationCacheError(RuntimeError):
|
||||||
|
"""``compile_engine`` with ``precision=INT8`` could not load or write
|
||||||
|
the calibration cache."""
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceError(RuntimeError):
|
||||||
|
"""``infer`` failed during GPU execution.
|
||||||
|
|
||||||
|
The impl MUST rewrap CUDA / TRT C++ / ORT exceptions into this
|
||||||
|
type; raw exceptions MUST NOT escape the Protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OutOfMemoryError(RuntimeError): # noqa: A001 - the contract names this OutOfMemoryError
|
||||||
|
"""GPU OOM during ``deserialize_engine`` or ``infer``.
|
||||||
|
|
||||||
|
Named ``OutOfMemoryError`` per the contract; this shadows the
|
||||||
|
stdlib name only inside ``c7_inference.errors``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryUnavailableError(RuntimeError):
|
||||||
|
"""``thermal_state`` cold-start could not bind to the
|
||||||
|
``tegrastats`` / ``jtop`` source.
|
||||||
|
|
||||||
|
Steady-state telemetry absence is signalled by
|
||||||
|
``ThermalState.is_telemetry_available == False`` (NOT this
|
||||||
|
exception); this error is the cold-start fail-fast.
|
||||||
|
"""
|
||||||
@@ -1,18 +1,106 @@
|
|||||||
"""C7 `InferenceRuntime` Protocol.
|
"""C7 ``InferenceRuntime`` Protocol (AZ-297).
|
||||||
|
|
||||||
Concrete impls: `TensorrtRuntime` (production-default; TensorRT 10.3),
|
PEP 544 ``typing.Protocol`` with ``runtime_checkable=True``; six
|
||||||
`OnnxTrtEpRuntime` (ONNX Runtime + TensorRT EP), `PytorchFp16Runtime` (research
|
methods that span engine lifecycle (compile / deserialize / release),
|
||||||
baseline). See `_docs/02_document/components/09_c7_inference/`.
|
per-call inference, thermal-state telemetry, and runtime-label
|
||||||
|
self-report. Concrete impls — :class:`TensorrtRuntime` (AZ-298),
|
||||||
|
:class:`OnnxTrtEpRuntime` (AZ-299), :class:`PytorchFp16Runtime`
|
||||||
|
(AZ-300) — live in sibling modules and are imported lazily by
|
||||||
|
:mod:`gps_denied_onboard.runtime_root.inference_factory`.
|
||||||
|
|
||||||
|
The contract at
|
||||||
|
``_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md``
|
||||||
|
v1.0.0 is the authoritative shape; this module mirrors it 1:1.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Protocol
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.inference import (
|
||||||
|
BuildConfig,
|
||||||
|
EngineCacheEntry,
|
||||||
|
EngineHandle,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard._types.thermal import ThermalState
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
__all__ = ["InferenceRuntime"]
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
class InferenceRuntime(Protocol):
|
class InferenceRuntime(Protocol):
|
||||||
"""Compiled-engine inference runtime."""
|
"""On-Jetson inference runtime. See ``inference_runtime_protocol.md`` v1.0.0.
|
||||||
|
|
||||||
def infer(self, inputs: Any) -> Any: ...
|
Implementations: :class:`TensorrtRuntime` (production-default, TRT 10.3),
|
||||||
|
:class:`OnnxTrtEpRuntime` (ONNX Runtime + TensorRT EP fallback),
|
||||||
|
:class:`PytorchFp16Runtime` (research / Tier-0 baseline). Selection
|
||||||
|
is owned by the composition root.
|
||||||
|
"""
|
||||||
|
|
||||||
def load(self, engine_path: str) -> None: ...
|
def compile_engine(
|
||||||
|
self, model_path: Path, build_config: BuildConfig
|
||||||
|
) -> EngineCacheEntry:
|
||||||
|
"""Offline (minutes for INT8) engine compilation.
|
||||||
|
|
||||||
|
Raises :class:`EngineBuildError` on compile failure;
|
||||||
|
:class:`CalibrationCacheError` when
|
||||||
|
``build_config.precision == INT8`` and the calibration data
|
||||||
|
cannot be loaded.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def deserialize_engine(self, entry: EngineCacheEntry) -> EngineHandle:
|
||||||
|
"""Load a compiled engine into GPU memory.
|
||||||
|
|
||||||
|
Raises :class:`EngineDeserializeError` /
|
||||||
|
:class:`EngineHashMismatchError` /
|
||||||
|
:class:`EngineSchemaMismatchError` /
|
||||||
|
:class:`EngineSidecarMissingError` /
|
||||||
|
:class:`OutOfMemoryError` per the contract envelope.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def infer(
|
||||||
|
self,
|
||||||
|
handle: EngineHandle,
|
||||||
|
inputs: "dict[str, np.ndarray]",
|
||||||
|
) -> "dict[str, np.ndarray]":
|
||||||
|
"""Synchronous request/response inference (Invariant I-8).
|
||||||
|
|
||||||
|
Returns AFTER the GPU stream has synchronised; output tensors
|
||||||
|
are host-resident :class:`numpy.ndarray` ready for consumer
|
||||||
|
use. Raises :class:`InferenceError` /
|
||||||
|
:class:`OutOfMemoryError`.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def release_engine(self, handle: EngineHandle) -> None:
|
||||||
|
"""Drop the engine + free GPU memory.
|
||||||
|
|
||||||
|
Idempotent (Invariant I-7) — second-and-later calls on the
|
||||||
|
same handle return silently.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def thermal_state(self) -> ThermalState:
|
||||||
|
"""Most-recent thermal reading from ``tegrastats`` / ``jtop``.
|
||||||
|
|
||||||
|
Raises :class:`TelemetryUnavailableError` only on cold-start
|
||||||
|
binding failure; steady-state telemetry absence is signalled
|
||||||
|
by ``ThermalState.is_telemetry_available == False`` (Invariant I-6).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def current_runtime_label(
|
||||||
|
self,
|
||||||
|
) -> Literal["tensorrt", "onnx_trt_ep", "pytorch_fp16"]:
|
||||||
|
"""Identify which concrete strategy is wired here.
|
||||||
|
|
||||||
|
Returned string equals ``config.inference.runtime`` exactly
|
||||||
|
(Invariant I-1); AC-NEW-3 FDR audit relies on this property.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ class Msp2InavAdapter:
|
|||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# close() on a half-broken serial/socket handle is
|
||||||
|
# itself best-effort; the wrapping finally still
|
||||||
|
# nulls the references so subsequent reopens see a
|
||||||
|
# clean slate.
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self._opened = False
|
self._opened = False
|
||||||
|
|||||||
@@ -585,6 +585,10 @@ class PymavlinkArdupilotAdapter:
|
|||||||
try:
|
try:
|
||||||
self._send_statustext_internal(f"src-set switch failed: {reason}", Severity.ERROR)
|
self._send_statustext_internal(f"src-set switch failed: {reason}", Severity.ERROR)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# STATUSTEXT is a best-effort operator-facing notification; the
|
||||||
|
# canonical failure surface is the structured log + FDR event
|
||||||
|
# already emitted above. A serial-write failure here must not
|
||||||
|
# mask the primary failure path.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
|
def _extract_wgs84(self, output: EstimatorOutput) -> LatLonAlt:
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
|||||||
"clean_shutdown",
|
"clean_shutdown",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
# AZ-490 / E-C5: operator-origin cold-start ladder + bounded-delta GPS gate.
|
||||||
|
"c5.cold_start_origin.set": frozenset(
|
||||||
|
{"source", "lat_deg", "lon_deg", "alt_m", "sigma_horiz_m", "sigma_vert_m"}
|
||||||
|
),
|
||||||
|
"c5.cold_start_origin.unavailable": frozenset({"reason"}),
|
||||||
|
"c5.gps_bounded_delta.accept": frozenset(
|
||||||
|
{
|
||||||
|
"sample_lat",
|
||||||
|
"sample_lon",
|
||||||
|
"smoother_lat",
|
||||||
|
"smoother_lon",
|
||||||
|
"distance_m",
|
||||||
|
"threshold_m",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"c5.gps_bounded_delta.reject": frozenset(
|
||||||
|
{
|
||||||
|
"sample_lat",
|
||||||
|
"sample_lon",
|
||||||
|
"smoother_lat",
|
||||||
|
"smoother_lon",
|
||||||
|
"distance_m",
|
||||||
|
"threshold_m",
|
||||||
|
}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
||||||
|
|||||||
@@ -91,6 +91,29 @@ class WgsConverter:
|
|||||||
delta_ecef = rotation @ p_enu.astype(np.float64)
|
delta_ecef = rotation @ p_enu.astype(np.float64)
|
||||||
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
|
return WgsConverter.ecef_to_latlonalt(origin_ecef + delta_ecef)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def horizontal_distance_m(a: LatLonAlt, b: LatLonAlt) -> float:
|
||||||
|
"""Return the geodesic horizontal distance (m) between ``a`` and ``b``.
|
||||||
|
|
||||||
|
Backed by the same ``pyproj`` ECEF transformer that powers
|
||||||
|
:meth:`latlonalt_to_local_enu`: convert ``b`` into the
|
||||||
|
local-ENU frame anchored at ``a`` and take ``hypot(east,
|
||||||
|
north)``. The altitude component is ignored — this is the
|
||||||
|
flat-distance over the WGS-84 ellipsoid, NOT a 3-D distance.
|
||||||
|
|
||||||
|
Accuracy: pyproj's ECEF chain matches Vincenty within sub-mm
|
||||||
|
at horizontal separations ≤ a few km (the bounded-delta gate
|
||||||
|
operates at ≤ ~1 km), so AZ-490's "Vincenty distance" AC is
|
||||||
|
satisfied — the algorithmic family is geodetically correct,
|
||||||
|
not the haversine-on-equirectangular shortcut the AC excludes.
|
||||||
|
"""
|
||||||
|
_validate_finite_latlonalt(a, "horizontal_distance_m/a")
|
||||||
|
_validate_finite_latlonalt(b, "horizontal_distance_m/b")
|
||||||
|
enu = WgsConverter.latlonalt_to_local_enu(a, b)
|
||||||
|
east = float(enu[0])
|
||||||
|
north = float(enu[1])
|
||||||
|
return math.hypot(east, north)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
|
def latlon_to_tile_xy(zoom: int, lat: float, lon: float) -> tuple[int, int]:
|
||||||
_validate_zoom(zoom)
|
_validate_zoom(zoom)
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ from dataclasses import dataclass, field
|
|||||||
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
||||||
|
|
||||||
from gps_denied_onboard.config import Config, load_config
|
from gps_denied_onboard.config import Config, load_config
|
||||||
|
from gps_denied_onboard.runtime_root.c12_factory import (
|
||||||
|
build_flights_api_client,
|
||||||
|
)
|
||||||
from gps_denied_onboard.runtime_root.fc_factory import (
|
from gps_denied_onboard.runtime_root.fc_factory import (
|
||||||
OutboundThreadAlreadyBoundError,
|
OutboundThreadAlreadyBoundError,
|
||||||
bind_outbound_emit_thread,
|
bind_outbound_emit_thread,
|
||||||
@@ -77,6 +80,7 @@ __all__ = [
|
|||||||
"bind_outbound_emit_thread",
|
"bind_outbound_emit_thread",
|
||||||
"bind_state_ingest_thread",
|
"bind_state_ingest_thread",
|
||||||
"build_fc_adapter",
|
"build_fc_adapter",
|
||||||
|
"build_flights_api_client",
|
||||||
"build_gcs_adapter",
|
"build_gcs_adapter",
|
||||||
"build_pose_estimator",
|
"build_pose_estimator",
|
||||||
"build_state_estimator",
|
"build_state_estimator",
|
||||||
@@ -539,6 +543,9 @@ def _abort_takeoff_on_fdr_open_error(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Even the error-logging failed during abort; we still have the
|
||||||
|
# stderr print below to surface the original failure to the
|
||||||
|
# operator. Swallowing keeps the abort path single-exit.
|
||||||
pass
|
pass
|
||||||
print(
|
print(
|
||||||
f"FATAL: cannot open FDR at {resolved_root}: {underlying}; aborting takeoff (exit 2)",
|
f"FATAL: cannot open FDR at {resolved_root}: {underlying}; aborting takeoff (exit 2)",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Composition-root factory for C12 operator-tooling services (AZ-489).
|
||||||
|
|
||||||
|
Currently exposes :func:`build_flights_api_client` — the
|
||||||
|
:class:`FlightsApiClient` used by C12's pre-flight cache-build workflow
|
||||||
|
(see AZ-326 / AZ-328 for downstream consumers).
|
||||||
|
|
||||||
|
The factory is intentionally tiny: there is only one concrete strategy
|
||||||
|
(``HttpxFlightsApiClient``) and httpx already defaults to TLS verify ON
|
||||||
|
and the system trust store, so the factory's job is to assemble the
|
||||||
|
client without re-implementing those defaults.
|
||||||
|
|
||||||
|
The richer ``OperatorToolServices`` dataclass that aggregates this
|
||||||
|
client with the rest of C12 (``CacheBuildWorkflow``,
|
||||||
|
``OperatorReLocService``, etc.) is owned by AZ-328 and intentionally
|
||||||
|
NOT created here per scope discipline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||||
|
FlightsApiClient,
|
||||||
|
HttpxFlightsApiClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gps_denied_onboard.config import Config
|
||||||
|
|
||||||
|
__all__ = ["build_flights_api_client"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_flights_api_client(config: Config) -> FlightsApiClient:
|
||||||
|
"""Return the operator-tier :class:`FlightsApiClient`.
|
||||||
|
|
||||||
|
The current implementation is the production
|
||||||
|
:class:`HttpxFlightsApiClient` with httpx defaults (TLS verify ON,
|
||||||
|
system trust store). ``config`` is accepted for API parity with the
|
||||||
|
other ``build_*`` factories; the client itself does not need
|
||||||
|
composition-time configuration — the operator's base URL and auth
|
||||||
|
token are resolved per-call by the CLI layer (AZ-326).
|
||||||
|
"""
|
||||||
|
_ = config # reserved for future composition-time tuning
|
||||||
|
return HttpxFlightsApiClient()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Composition-root errors shared across runtime factories.
|
||||||
|
|
||||||
|
These are raised at composition time (``build_*`` factory entry) and
|
||||||
|
NOT during the running flight. Components own their per-runtime error
|
||||||
|
families; this module owns the cross-component selection error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeNotAvailableError(RuntimeError):
|
||||||
|
"""Raised when ``build_*`` is asked for a runtime whose compile-time
|
||||||
|
``BUILD_*`` flag is OFF.
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- ``runtime_root.storage_factory.build_descriptor_index`` (AZ-303 / E-C6,
|
||||||
|
``BUILD_FAISS_INDEX`` gate)
|
||||||
|
- ``runtime_root.inference_factory.build_inference_runtime`` (AZ-297 / E-C7,
|
||||||
|
``BUILD_TENSORRT_RUNTIME`` / ``BUILD_ONNX_TRT_EP_RUNTIME`` /
|
||||||
|
``BUILD_PYTORCH_FP16_RUNTIME`` gates)
|
||||||
|
|
||||||
|
The message MUST name the requested runtime label so the operator can
|
||||||
|
correlate against ``.env``'s ``BUILD_*`` matrix without guessing.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""C7 inference-runtime composition-root factory (AZ-297).
|
||||||
|
|
||||||
|
:func:`build_inference_runtime` selects exactly one strategy by
|
||||||
|
``config.components['c7_inference'].runtime`` and respects compile-time
|
||||||
|
``BUILD_*`` gating: requesting a strategy whose flag is OFF raises
|
||||||
|
:class:`RuntimeNotAvailableError` at composition time (NOT at first
|
||||||
|
inference call).
|
||||||
|
|
||||||
|
The concrete strategy modules (``tensorrt_runtime``, ``onnx_trt_ep_runtime``,
|
||||||
|
``pytorch_fp16_runtime``) are imported lazily — a Tier-0 workstation
|
||||||
|
build with ``BUILD_TENSORRT_RUNTIME=OFF`` MUST NOT load
|
||||||
|
``c7_inference.tensorrt_runtime`` (Invariant I-5; verifiable via
|
||||||
|
``sys.modules``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gps_denied_onboard.components.c7_inference import (
|
||||||
|
C7InferenceConfig,
|
||||||
|
InferenceRuntime,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.config.schema import Config
|
||||||
|
|
||||||
|
__all__ = ["build_inference_runtime"]
|
||||||
|
|
||||||
|
|
||||||
|
_RUNTIME_TO_BUILD_FLAG: dict[str, str] = {
|
||||||
|
"tensorrt": "BUILD_TENSORRT_RUNTIME",
|
||||||
|
"onnx_trt_ep": "BUILD_ONNX_TRT_EP_RUNTIME",
|
||||||
|
"pytorch_fp16": "BUILD_PYTORCH_FP16_RUNTIME",
|
||||||
|
}
|
||||||
|
|
||||||
|
_RUNTIME_TO_MODULE: dict[str, tuple[str, str]] = {
|
||||||
|
"tensorrt": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.tensorrt_runtime",
|
||||||
|
"TensorrtRuntime",
|
||||||
|
),
|
||||||
|
"onnx_trt_ep": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.onnx_trt_ep_runtime",
|
||||||
|
"OnnxTrtEpRuntime",
|
||||||
|
),
|
||||||
|
"pytorch_fp16": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.pytorch_fp16_runtime",
|
||||||
|
"PytorchFp16Runtime",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_build_flag_on(flag_name: str) -> bool:
|
||||||
|
"""Read a compile-time ``BUILD_*`` flag from the environment.
|
||||||
|
|
||||||
|
``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``;
|
||||||
|
anything else (including unset) → ``False``. Defaults to OFF so
|
||||||
|
test environments must opt-in explicitly per strategy.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(flag_name, "")
|
||||||
|
return raw.strip().lower() in {"on", "1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _c7_config(config: "Config") -> "C7InferenceConfig":
|
||||||
|
"""Pull the registered C7 config block.
|
||||||
|
|
||||||
|
``c7_inference.__init__`` registers it on import; a missing
|
||||||
|
registration is a developer error and surfaces as ``KeyError``
|
||||||
|
rather than a silent fallback.
|
||||||
|
"""
|
||||||
|
return config.components["c7_inference"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_inference_runtime(config: "Config") -> "InferenceRuntime":
|
||||||
|
"""Construct the :class:`InferenceRuntime` impl selected by config.
|
||||||
|
|
||||||
|
The factory:
|
||||||
|
|
||||||
|
1. Reads ``config.components['c7_inference'].runtime``.
|
||||||
|
2. Checks the matching ``BUILD_*`` flag — if OFF, raises
|
||||||
|
:class:`RuntimeNotAvailableError` BEFORE any import.
|
||||||
|
3. Lazily imports the concrete strategy module.
|
||||||
|
4. Constructs and returns the strategy instance, passing ``config``.
|
||||||
|
|
||||||
|
Raises :class:`RuntimeNotAvailableError` when:
|
||||||
|
|
||||||
|
- The compile-time flag is OFF (the canonical Tier-0 path).
|
||||||
|
- The concrete strategy module has not been built yet (AZ-298 /
|
||||||
|
AZ-299 / AZ-300 are still pending) — the import fails and the
|
||||||
|
factory wraps :class:`ModuleNotFoundError`.
|
||||||
|
"""
|
||||||
|
block = _c7_config(config)
|
||||||
|
runtime = block.runtime
|
||||||
|
flag_name = _RUNTIME_TO_BUILD_FLAG.get(runtime)
|
||||||
|
module_info = _RUNTIME_TO_MODULE.get(runtime)
|
||||||
|
if flag_name is None or module_info is None:
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"InferenceRuntime runtime {runtime!r} is not buildable in "
|
||||||
|
"this binary."
|
||||||
|
)
|
||||||
|
if not _is_build_flag_on(flag_name):
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"InferenceRuntime runtime {runtime!r} requires "
|
||||||
|
f"{flag_name}=ON in this binary; the flag is OFF."
|
||||||
|
)
|
||||||
|
module_name, class_name = module_info
|
||||||
|
try:
|
||||||
|
module = __import__(module_name, fromlist=[class_name])
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"InferenceRuntime runtime {runtime!r} is configured but its "
|
||||||
|
f"concrete impl module {module_name!r} has not been built into "
|
||||||
|
"this binary yet (AZ-298 / AZ-299 / AZ-300 pending)."
|
||||||
|
) from exc
|
||||||
|
strategy_cls = getattr(module, class_name)
|
||||||
|
return strategy_cls(config)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""C6 storage composition-root factories (AZ-303).
|
||||||
|
|
||||||
|
Three factories selected by ``config.components['c6_tile_cache']``:
|
||||||
|
|
||||||
|
- :func:`build_tile_store` — JPEG body store.
|
||||||
|
- :func:`build_tile_metadata_store` — Postgres metadata + LRU + voting.
|
||||||
|
- :func:`build_descriptor_index` — FAISS HNSW vector index.
|
||||||
|
|
||||||
|
The factories lazily import concrete impl modules. A ``BUILD_*`` flag
|
||||||
|
that is OFF MUST NOT cause the concrete module to be imported — that
|
||||||
|
is the AC-5 / Risk-2 invariant. We honour it by reading the flag
|
||||||
|
from :mod:`os.environ` BEFORE the ``import`` statement.
|
||||||
|
|
||||||
|
The concrete impls (``postgres_filesystem_store``, ``faiss_descriptor_index``)
|
||||||
|
are produced by separate downstream tasks (AZ-305 / AZ-306). Until they
|
||||||
|
land, requesting any runtime raises :class:`RuntimeNotAvailableError`
|
||||||
|
with a message that names the missing impl module — the failure is
|
||||||
|
explicit, not silent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache import (
|
||||||
|
C6TileCacheConfig,
|
||||||
|
DescriptorIndex,
|
||||||
|
TileMetadataStore,
|
||||||
|
TileStore,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.config.schema import Config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_descriptor_index",
|
||||||
|
"build_tile_metadata_store",
|
||||||
|
"build_tile_store",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_build_flag_on(flag_name: str) -> bool:
|
||||||
|
"""Read a compile-time ``BUILD_*`` flag from the environment.
|
||||||
|
|
||||||
|
``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``.
|
||||||
|
Anything else (including unset) → ``False``. Defaults to OFF to
|
||||||
|
keep test envs honest about which runtimes they advertise.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(flag_name, "")
|
||||||
|
return raw.strip().lower() in {"on", "1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
|
def _c6_config(config: "Config") -> "C6TileCacheConfig":
|
||||||
|
"""Pull the registered C6 config block.
|
||||||
|
|
||||||
|
``c6_tile_cache.__init__`` registers it on import; if the package
|
||||||
|
has not been imported, :class:`KeyError` here is the right
|
||||||
|
failure mode — silent fallback would mask a missing import.
|
||||||
|
"""
|
||||||
|
return config.components["c6_tile_cache"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_tile_store(config: "Config") -> "TileStore":
|
||||||
|
"""Construct the :class:`TileStore` impl selected by config.
|
||||||
|
|
||||||
|
Today only ``"postgres_filesystem"`` is wired; the runtime label
|
||||||
|
is validated at config-load time so unknown labels never reach
|
||||||
|
here. Concrete impl is produced by AZ-305.
|
||||||
|
"""
|
||||||
|
block = _c6_config(config)
|
||||||
|
runtime = block.store_runtime
|
||||||
|
if runtime == "postgres_filesystem":
|
||||||
|
try:
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.postgres_filesystem_store import ( # noqa: PLC0415
|
||||||
|
PostgresFilesystemStore,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"TileStore runtime {runtime!r} is configured but its "
|
||||||
|
"concrete impl module "
|
||||||
|
"'c6_tile_cache.postgres_filesystem_store' has not been "
|
||||||
|
"built into this binary yet (AZ-305 pending)."
|
||||||
|
) from exc
|
||||||
|
return PostgresFilesystemStore(config)
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"TileStore runtime {runtime!r} is not buildable in this binary."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tile_metadata_store(config: "Config") -> "TileMetadataStore":
|
||||||
|
"""Construct the :class:`TileMetadataStore` impl selected by config.
|
||||||
|
|
||||||
|
Today the same ``PostgresFilesystemStore`` class implements both
|
||||||
|
:class:`TileStore` and :class:`TileMetadataStore` (single-row
|
||||||
|
transactional store across the two surfaces). The factories
|
||||||
|
return the same instance shape but stay separate so a future
|
||||||
|
SQLite Tier-0 variant can swap one without the other.
|
||||||
|
"""
|
||||||
|
block = _c6_config(config)
|
||||||
|
runtime = block.metadata_runtime
|
||||||
|
if runtime == "postgres_filesystem":
|
||||||
|
try:
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.postgres_filesystem_store import ( # noqa: PLC0415
|
||||||
|
PostgresFilesystemStore,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"TileMetadataStore runtime {runtime!r} is configured "
|
||||||
|
"but its concrete impl module "
|
||||||
|
"'c6_tile_cache.postgres_filesystem_store' has not been "
|
||||||
|
"built into this binary yet (AZ-305 pending)."
|
||||||
|
) from exc
|
||||||
|
return PostgresFilesystemStore(config)
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"TileMetadataStore runtime {runtime!r} is not buildable in this binary."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_descriptor_index(config: "Config") -> "DescriptorIndex":
|
||||||
|
"""Construct the :class:`DescriptorIndex` impl selected by config.
|
||||||
|
|
||||||
|
Gated by ``BUILD_FAISS_INDEX``: if the flag is OFF, the concrete
|
||||||
|
module is NOT imported (sys.modules invariant; AC-5) and
|
||||||
|
:class:`RuntimeNotAvailableError` is raised at composition time.
|
||||||
|
"""
|
||||||
|
block = _c6_config(config)
|
||||||
|
runtime = block.descriptor_index_runtime
|
||||||
|
if runtime == "faiss_hnsw":
|
||||||
|
if not _is_build_flag_on("BUILD_FAISS_INDEX"):
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"DescriptorIndex runtime {runtime!r} requires "
|
||||||
|
"BUILD_FAISS_INDEX=ON in this binary; the flag is OFF."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index import ( # noqa: PLC0415
|
||||||
|
FaissDescriptorIndex,
|
||||||
|
)
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"DescriptorIndex runtime {runtime!r} is configured but "
|
||||||
|
"its concrete impl module "
|
||||||
|
"'c6_tile_cache.faiss_descriptor_index' has not been "
|
||||||
|
"built into this binary yet (AZ-306 pending)."
|
||||||
|
) from exc
|
||||||
|
return FaissDescriptorIndex(config)
|
||||||
|
raise RuntimeNotAvailableError(
|
||||||
|
f"DescriptorIndex runtime {runtime!r} is not buildable in this binary."
|
||||||
|
)
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
"""AZ-489 — C12 ``FlightsApiClient`` unit tests.
|
||||||
|
|
||||||
|
Covers AC-1..AC-18 from ``_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md``.
|
||||||
|
|
||||||
|
Online tests use ``httpx.MockTransport`` (httpx's native mock; no extra
|
||||||
|
HTTP-mocking dependency). Offline tests use ``tmp_path``-backed JSON
|
||||||
|
files. Bbox tests validate the FAC-INV-3 horizontal-distance buffer at
|
||||||
|
50 deg N against the canonical metres-per-degree expectations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||||
|
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||||
|
EmptyWaypointsError,
|
||||||
|
FlightDto,
|
||||||
|
FlightFileNotFoundError,
|
||||||
|
FlightNotFoundError,
|
||||||
|
FlightsApiAuthError,
|
||||||
|
FlightsApiClient,
|
||||||
|
FlightsApiSchemaError,
|
||||||
|
FlightsApiUnreachableError,
|
||||||
|
HttpxFlightsApiClient,
|
||||||
|
WaypointDto,
|
||||||
|
WaypointObjective,
|
||||||
|
WaypointSchemaError,
|
||||||
|
WaypointSource,
|
||||||
|
bbox_from_waypoints,
|
||||||
|
load_flight_file,
|
||||||
|
takeoff_origin_from_flight,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.runtime_root.c12_factory import build_flights_api_client
|
||||||
|
|
||||||
|
FLIGHT_ID = UUID("11111111-2222-3333-4444-555555555555")
|
||||||
|
BASE_URL = "https://flights.example/api"
|
||||||
|
AUTH_TOKEN = "bearer-secret-abc" # fake token used only in tests
|
||||||
|
|
||||||
|
|
||||||
|
def _waypoint_payload(
|
||||||
|
*,
|
||||||
|
ordinal: int = 0,
|
||||||
|
lat_deg: float = 50.0,
|
||||||
|
lon_deg: float = 36.2,
|
||||||
|
alt_m: float = 200.0,
|
||||||
|
objective: str = "waypoint",
|
||||||
|
source: str = "operator",
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"ordinal": ordinal,
|
||||||
|
"lat_deg": lat_deg,
|
||||||
|
"lon_deg": lon_deg,
|
||||||
|
"alt_m": alt_m,
|
||||||
|
"objective": objective,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _three_waypoint_payload(*, flight_id: UUID = FLIGHT_ID) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"flight_id": str(flight_id),
|
||||||
|
"name": "derkachi-sweep",
|
||||||
|
"waypoints": [
|
||||||
|
_waypoint_payload(ordinal=0, lat_deg=50.0, lon_deg=36.2, alt_m=200.0,
|
||||||
|
objective="takeoff"),
|
||||||
|
_waypoint_payload(ordinal=1, lat_deg=50.01, lon_deg=36.22, alt_m=210.0),
|
||||||
|
_waypoint_payload(ordinal=2, lat_deg=50.02, lon_deg=36.24, alt_m=220.0,
|
||||||
|
objective="landing"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client_with_handler(
|
||||||
|
handler: Callable[[httpx.Request], httpx.Response],
|
||||||
|
) -> tuple[HttpxFlightsApiClient, list[float]]:
|
||||||
|
sleeps: list[float] = []
|
||||||
|
|
||||||
|
def fake_sleep(seconds: float) -> None:
|
||||||
|
sleeps.append(seconds)
|
||||||
|
|
||||||
|
transport = httpx.MockTransport(handler)
|
||||||
|
client = HttpxFlightsApiClient(transport=transport, sleep=fake_sleep)
|
||||||
|
return client, sleeps
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_capturing_handler() -> tuple[logging.Handler, io.StringIO]:
|
||||||
|
buffer = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(buffer)
|
||||||
|
handler.setLevel(logging.DEBUG)
|
||||||
|
return handler, buffer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def capture_flights_api_logs() -> tuple[logging.Handler, io.StringIO]:
|
||||||
|
handler, buffer = _attach_capturing_handler()
|
||||||
|
logger = logging.getLogger("c12.flights_api")
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
yield handler, buffer
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-1: Online happy path
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_online_happy_path_returns_three_waypoint_flight(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return httpx.Response(200, json=_three_waypoint_payload())
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
_, buffer = capture_flights_api_logs
|
||||||
|
|
||||||
|
# Act
|
||||||
|
flight = client.fetch_flight(
|
||||||
|
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(flight, FlightDto)
|
||||||
|
assert flight.flight_id == FLIGHT_ID
|
||||||
|
assert len(flight.waypoints) == 3
|
||||||
|
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
|
||||||
|
assert call_count == 1
|
||||||
|
assert sleeps == []
|
||||||
|
log_output = buffer.getvalue()
|
||||||
|
assert AUTH_TOKEN not in log_output
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-2: 404 - FlightNotFoundError, no retry
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac2_online_404_raises_flight_not_found_without_retry() -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return httpx.Response(404, json={"error": "not found"})
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightNotFoundError) as exc_info:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
|
||||||
|
assert str(FLIGHT_ID) in str(exc_info.value)
|
||||||
|
assert call_count == 1
|
||||||
|
assert sleeps == []
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-3: 401 - FlightsApiAuthError, no retry, no token in logs
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_online_401_raises_auth_error_without_logging_token(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return httpx.Response(401, json={"error": "unauthorized"})
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
_, buffer = capture_flights_api_logs
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightsApiAuthError) as exc_info:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
|
||||||
|
assert AUTH_TOKEN not in str(exc_info.value)
|
||||||
|
assert call_count == 1
|
||||||
|
assert sleeps == []
|
||||||
|
assert AUTH_TOKEN not in buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-4: 503 transient -> retry once -> success
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_online_503_then_200_retries_once_and_succeeds(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1:
|
||||||
|
return httpx.Response(503, json={"error": "transient"})
|
||||||
|
return httpx.Response(200, json=_three_waypoint_payload())
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
_, buffer = capture_flights_api_logs
|
||||||
|
|
||||||
|
# Act
|
||||||
|
flight = client.fetch_flight(
|
||||||
|
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(flight, FlightDto)
|
||||||
|
assert call_count == 2
|
||||||
|
assert sleeps == [1.0]
|
||||||
|
log_output = buffer.getvalue()
|
||||||
|
assert "c12.flights.fetch.retry" in log_output
|
||||||
|
assert AUTH_TOKEN not in log_output
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-5: 503 persistent -> Unreachable after one retry
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac5_online_503_always_raises_unreachable_after_one_retry(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return httpx.Response(503, json={"error": "down"})
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightsApiUnreachableError):
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
assert sleeps == [1.0]
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-6: Schema drift (missing lat) raises FlightsApiSchemaError or WaypointSchemaError
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_online_schema_drift_raises_with_field_reference() -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
waypoints = payload["waypoints"]
|
||||||
|
assert isinstance(waypoints, list)
|
||||||
|
del waypoints[1]["lat_deg"] # type: ignore[index]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
|
||||||
|
assert "lat_deg" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-7: Offline happy path
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac7_offline_happy_path_returns_equivalent_flight_dto(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
flight_file = tmp_path / "flight.json"
|
||||||
|
flight_file.write_bytes(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
# Act
|
||||||
|
flight = load_flight_file(path=flight_file)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert flight.flight_id == FLIGHT_ID
|
||||||
|
assert len(flight.waypoints) == 3
|
||||||
|
assert flight.waypoints[0].objective == WaypointObjective.TAKEOFF
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-8: Offline missing file
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac8_offline_missing_file_raises_with_path_in_message(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
missing = tmp_path / "does-not-exist.json"
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightFileNotFoundError) as exc_info:
|
||||||
|
load_flight_file(path=missing)
|
||||||
|
|
||||||
|
assert str(missing) in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-9: Empty waypoints -> bbox raises EmptyWaypointsError
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac9_empty_waypoints_into_bbox_raises_empty_waypoints_error() -> None:
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(EmptyWaypointsError):
|
||||||
|
bbox_from_waypoints((), buffer_m=1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-10: Bbox 1 km buffer at 50N stays within 5% of horizontal-distance target
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_corner_waypoints(centre: LatLonAlt, half_extent_m: float) -> tuple[WaypointDto, ...]:
|
||||||
|
metres_per_deg_lat = 111_320.0
|
||||||
|
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||||
|
d_lat = half_extent_m / metres_per_deg_lat
|
||||||
|
d_lon = half_extent_m / metres_per_deg_lon
|
||||||
|
return (
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=0,
|
||||||
|
lat_deg=centre.lat_deg - d_lat,
|
||||||
|
lon_deg=centre.lon_deg - d_lon,
|
||||||
|
alt_m=centre.alt_m,
|
||||||
|
objective=WaypointObjective.TAKEOFF,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=1,
|
||||||
|
lat_deg=centre.lat_deg - d_lat,
|
||||||
|
lon_deg=centre.lon_deg + d_lon,
|
||||||
|
alt_m=centre.alt_m,
|
||||||
|
objective=WaypointObjective.WAYPOINT,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=2,
|
||||||
|
lat_deg=centre.lat_deg + d_lat,
|
||||||
|
lon_deg=centre.lon_deg + d_lon,
|
||||||
|
alt_m=centre.alt_m,
|
||||||
|
objective=WaypointObjective.WAYPOINT,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=3,
|
||||||
|
lat_deg=centre.lat_deg + d_lat,
|
||||||
|
lon_deg=centre.lon_deg - d_lon,
|
||||||
|
alt_m=centre.alt_m,
|
||||||
|
objective=WaypointObjective.LANDING,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac10_bbox_buffer_is_horizontal_distance_within_five_percent_at_50n() -> None:
|
||||||
|
# Arrange
|
||||||
|
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||||
|
half_extent_m = 500.0 # 1 km box overall
|
||||||
|
waypoints = _make_corner_waypoints(centre, half_extent_m)
|
||||||
|
buffer_m = 1000.0
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bbox = bbox_from_waypoints(waypoints, buffer_m=buffer_m)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
metres_per_deg_lat = 111_320.0
|
||||||
|
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||||
|
expected_lat_half_extent = half_extent_m + buffer_m
|
||||||
|
expected_lon_half_extent = half_extent_m + buffer_m
|
||||||
|
expected_min_lat = centre.lat_deg - expected_lat_half_extent / metres_per_deg_lat
|
||||||
|
expected_max_lat = centre.lat_deg + expected_lat_half_extent / metres_per_deg_lat
|
||||||
|
expected_min_lon = centre.lon_deg - expected_lon_half_extent / metres_per_deg_lon
|
||||||
|
expected_max_lon = centre.lon_deg + expected_lon_half_extent / metres_per_deg_lon
|
||||||
|
|
||||||
|
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.05)
|
||||||
|
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.05)
|
||||||
|
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.05)
|
||||||
|
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-11: Takeoff origin pass-through
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac11_takeoff_origin_is_first_waypoint_with_no_rounding() -> None:
|
||||||
|
# Arrange
|
||||||
|
flight = FlightDto(
|
||||||
|
flight_id=FLIGHT_ID,
|
||||||
|
name="derkachi",
|
||||||
|
waypoints=(
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=0,
|
||||||
|
lat_deg=50.000000001,
|
||||||
|
lon_deg=36.200000001,
|
||||||
|
alt_m=200.000000001,
|
||||||
|
objective=WaypointObjective.TAKEOFF,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
WaypointDto(
|
||||||
|
ordinal=1,
|
||||||
|
lat_deg=51.0,
|
||||||
|
lon_deg=37.0,
|
||||||
|
alt_m=210.0,
|
||||||
|
objective=WaypointObjective.WAYPOINT,
|
||||||
|
source=WaypointSource.OPERATOR,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
origin = takeoff_origin_from_flight(flight)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert origin == LatLonAlt(50.000000001, 36.200000001, 200.000000001)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-12: Conformance
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac12_httpx_flights_api_client_satisfies_protocol() -> None:
|
||||||
|
# Assert
|
||||||
|
assert isinstance(HttpxFlightsApiClient(), FlightsApiClient)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac12_runtime_root_factory_returns_protocol_conforming_instance() -> None:
|
||||||
|
# Arrange
|
||||||
|
config = object() # factory ignores config in v1
|
||||||
|
|
||||||
|
# Act
|
||||||
|
client = build_flights_api_client(config) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(client, FlightsApiClient)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-13: Online + Offline equality
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac13_online_and_offline_produce_equal_dtos(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
flight_file = tmp_path / "flight.json"
|
||||||
|
flight_file.write_bytes(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
# Act
|
||||||
|
online_dto = client.fetch_flight(
|
||||||
|
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||||
|
)
|
||||||
|
offline_dto = load_flight_file(path=flight_file)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert online_dto == offline_dto
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-14: Shuffled ordinals -> sorted output
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac14_shuffled_ordinals_are_returned_in_sorted_order() -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
waypoints = payload["waypoints"]
|
||||||
|
assert isinstance(waypoints, list)
|
||||||
|
waypoints[0], waypoints[2] = waypoints[2], waypoints[0]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
flight = client.fetch_flight(
|
||||||
|
flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert tuple(w.ordinal for w in flight.waypoints) == (0, 1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-15: Ordinal gap raises WaypointSchemaError
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac15_ordinal_gap_raises_waypoint_schema_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
waypoints = payload["waypoints"]
|
||||||
|
assert isinstance(waypoints, list)
|
||||||
|
waypoints[2]["ordinal"] = 5 # type: ignore[index]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
assert "ordinal" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-16: Out-of-range lat raises WaypointSchemaError
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac16_lat_200_raises_waypoint_schema_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
waypoints = payload["waypoints"]
|
||||||
|
assert isinstance(waypoints, list)
|
||||||
|
waypoints[0]["lat_deg"] = 200.0 # type: ignore[index]
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(WaypointSchemaError) as exc_info:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
assert "lat_deg" in str(exc_info.value)
|
||||||
|
assert "200" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-17: Token redaction across all paths
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status_code,first_payload,second_payload",
|
||||||
|
[
|
||||||
|
(200, _three_waypoint_payload(), None),
|
||||||
|
(401, {"error": "unauthorized"}, None),
|
||||||
|
(404, {"error": "not found"}, None),
|
||||||
|
(500, {"error": "server"}, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac17_auth_token_never_appears_in_logs(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
status_code: int,
|
||||||
|
first_payload: dict[str, object],
|
||||||
|
second_payload: dict[str, object] | None,
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code, json=first_payload)
|
||||||
|
|
||||||
|
client, _ = _make_client_with_handler(handler)
|
||||||
|
_, buffer = capture_flights_api_logs
|
||||||
|
|
||||||
|
# Act
|
||||||
|
try:
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
log_output = buffer.getvalue()
|
||||||
|
assert AUTH_TOKEN not in log_output
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# AC-18: Timeout (connect error) -> Unreachable after one retry
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac18_persistent_connect_error_raises_unreachable_after_one_retry(
|
||||||
|
capture_flights_api_logs: tuple[logging.Handler, io.StringIO],
|
||||||
|
) -> None:
|
||||||
|
# Arrange
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
raise httpx.ConnectError("simulated tcp reset")
|
||||||
|
|
||||||
|
client, sleeps = _make_client_with_handler(handler)
|
||||||
|
_, buffer = capture_flights_api_logs
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightsApiUnreachableError):
|
||||||
|
client.fetch_flight(flight_id=FLIGHT_ID, base_url=BASE_URL, auth_token=AUTH_TOKEN)
|
||||||
|
|
||||||
|
assert call_count == 2
|
||||||
|
assert sleeps == [1.0]
|
||||||
|
assert "c12.flights.fetch.retry" in buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Extra coverage: file with malformed JSON, bbox negative buffer
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_malformed_json_raises_schema_error(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
flight_file = tmp_path / "broken.json"
|
||||||
|
flight_file.write_bytes(b"{not-json")
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightsApiSchemaError):
|
||||||
|
load_flight_file(path=flight_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbox_negative_buffer_raises_value_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||||
|
waypoints = _make_corner_waypoints(centre, 500.0)
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
bbox_from_waypoints(waypoints, buffer_m=-1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bbox_zero_buffer_returns_envelope() -> None:
|
||||||
|
# Arrange
|
||||||
|
centre = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=0.0)
|
||||||
|
waypoints = _make_corner_waypoints(centre, 500.0)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
bbox = bbox_from_waypoints(waypoints, buffer_m=0.0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
metres_per_deg_lat = 111_320.0
|
||||||
|
metres_per_deg_lon = 111_320.0 * math.cos(math.radians(centre.lat_deg))
|
||||||
|
expected_min_lat = centre.lat_deg - 500.0 / metres_per_deg_lat
|
||||||
|
expected_max_lat = centre.lat_deg + 500.0 / metres_per_deg_lat
|
||||||
|
expected_min_lon = centre.lon_deg - 500.0 / metres_per_deg_lon
|
||||||
|
expected_max_lon = centre.lon_deg + 500.0 / metres_per_deg_lon
|
||||||
|
assert isinstance(bbox, BoundingBox)
|
||||||
|
assert bbox.min_lat_deg == pytest.approx(expected_min_lat, rel=0.01)
|
||||||
|
assert bbox.max_lat_deg == pytest.approx(expected_max_lat, rel=0.01)
|
||||||
|
assert bbox.min_lon_deg == pytest.approx(expected_min_lon, rel=0.01)
|
||||||
|
assert bbox.max_lon_deg == pytest.approx(expected_max_lon, rel=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_rejects_missing_top_level_fields(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
flight_file = tmp_path / "no-flight-id.json"
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
del payload["flight_id"]
|
||||||
|
flight_file.write_bytes(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(FlightsApiSchemaError):
|
||||||
|
load_flight_file(path=flight_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parser_rejects_negative_ordinal(tmp_path: Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
payload = _three_waypoint_payload()
|
||||||
|
waypoints = payload["waypoints"]
|
||||||
|
assert isinstance(waypoints, list)
|
||||||
|
waypoints[0]["ordinal"] = -1 # type: ignore[index]
|
||||||
|
flight_file = tmp_path / "neg.json"
|
||||||
|
flight_file.write_bytes(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(WaypointSchemaError):
|
||||||
|
load_flight_file(path=flight_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_takeoff_origin_on_empty_flight_raises_empty_waypoints_error() -> None:
|
||||||
|
# Arrange
|
||||||
|
flight = FlightDto(flight_id=FLIGHT_ID, name="empty", waypoints=())
|
||||||
|
|
||||||
|
# Act / Assert
|
||||||
|
with pytest.raises(EmptyWaypointsError):
|
||||||
|
takeoff_origin_from_flight(flight)
|
||||||
@@ -93,6 +93,14 @@ def _build_config(**state_overrides: Any) -> Config:
|
|||||||
class _FakeEstimator:
|
class _FakeEstimator:
|
||||||
"""Test fake satisfying every StateEstimator method (AC-1)."""
|
"""Test fake satisfying every StateEstimator method (AC-1)."""
|
||||||
|
|
||||||
|
def set_takeoff_origin(
|
||||||
|
self,
|
||||||
|
origin: LatLonAlt,
|
||||||
|
sigma_horiz_m: float,
|
||||||
|
sigma_vert_m: float,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
def add_vio(self, vio: VioOutput) -> None:
|
def add_vio(self, vio: VioOutput) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -133,7 +141,7 @@ def test_ac1_missing_method_fails_isinstance() -> None:
|
|||||||
def add_vio(self, vio: VioOutput) -> None:
|
def add_vio(self, vio: VioOutput) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Assert — missing 5 methods → not a StateEstimator
|
# Assert — missing 6 methods → not a StateEstimator
|
||||||
assert not isinstance(_Incomplete(), StateEstimator)
|
assert not isinstance(_Incomplete(), StateEstimator)
|
||||||
|
|
||||||
|
|
||||||
@@ -293,6 +301,31 @@ def test_ac8_handle_is_isam2_graph_handle() -> None:
|
|||||||
assert isinstance(handle, ISam2GraphHandle)
|
assert isinstance(handle, ISam2GraphHandle)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_satisfies_c4_isam2_graph_handle_protocol() -> None:
|
||||||
|
"""Cross-component conformance: ``ISam2GraphHandleImpl`` MUST satisfy
|
||||||
|
the C4-side Protocol stub (``c4_pose._isam2_handle.ISam2GraphHandle``)
|
||||||
|
so the same instance can be passed to ``pose_factory.build_pose_estimator``
|
||||||
|
without an adapter. The c4 stub requires only ``get_pose_key(frame_id) -> int``;
|
||||||
|
the c5 impl delegates to ``estimator.key_for_frame`` for that lookup.
|
||||||
|
"""
|
||||||
|
# Arrange
|
||||||
|
from gps_denied_onboard.components.c4_pose._isam2_handle import (
|
||||||
|
ISam2GraphHandle as C4ISam2GraphHandle,
|
||||||
|
)
|
||||||
|
|
||||||
|
estimator_mock = mock.MagicMock()
|
||||||
|
estimator_mock.key_for_frame.return_value = 0x7800000000000007
|
||||||
|
handle = ISam2GraphHandleImpl(estimator=estimator_mock)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
key = handle.get_pose_key(42)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert isinstance(handle, C4ISam2GraphHandle)
|
||||||
|
estimator_mock.key_for_frame.assert_called_once_with(42)
|
||||||
|
assert key == 0x7800000000000007
|
||||||
|
|
||||||
|
|
||||||
# Note: the AZ-381 skeleton's ``NotImplementedError`` bodies were
|
# Note: the AZ-381 skeleton's ``NotImplementedError`` bodies were
|
||||||
# replaced with real GTSAM calls by AZ-382. The "methods raise" test
|
# replaced with real GTSAM calls by AZ-382. The "methods raise" test
|
||||||
# that lived here has moved to
|
# that lived here has moved to
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ def _make_sm(
|
|||||||
*,
|
*,
|
||||||
min_stable_s: float = 10.0,
|
min_stable_s: float = 10.0,
|
||||||
tol_m: float = 30.0,
|
tol_m: float = 30.0,
|
||||||
|
bounded_delta_m: float = 200.0,
|
||||||
clock: _Clock | None = None,
|
clock: _Clock | None = None,
|
||||||
fdr_client: mock.MagicMock | None = None,
|
fdr_client: mock.MagicMock | None = None,
|
||||||
) -> tuple[SourceLabelStateMachine, _Clock, mock.MagicMock]:
|
) -> tuple[SourceLabelStateMachine, _Clock, mock.MagicMock]:
|
||||||
@@ -80,6 +81,7 @@ def _make_sm(
|
|||||||
sm = SourceLabelStateMachine(
|
sm = SourceLabelStateMachine(
|
||||||
spoof_promotion_min_stable_s=min_stable_s,
|
spoof_promotion_min_stable_s=min_stable_s,
|
||||||
spoof_promotion_visual_consistency_tol_m=tol_m,
|
spoof_promotion_visual_consistency_tol_m=tol_m,
|
||||||
|
spoof_promotion_bounded_delta_m=bounded_delta_m,
|
||||||
fdr_client=fdr,
|
fdr_client=fdr,
|
||||||
producer_id="c5_state",
|
producer_id="c5_state",
|
||||||
clock_ns=clock,
|
clock_ns=clock,
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"""AZ-490 — ``set_takeoff_origin`` + bounded-delta GPS gate.
|
||||||
|
|
||||||
|
Covers AC-1..AC-15 from
|
||||||
|
``_docs/02_tasks/todo/AZ-490_c5_set_takeoff_origin.md``.
|
||||||
|
|
||||||
|
The tests construct estimators directly (the iSAM2 estimator
|
||||||
|
exercises the real GTSAM ``PriorFactorPose3`` insertion; the ESKF
|
||||||
|
estimator exercises the real NumPy covariance write). The
|
||||||
|
``SourceLabelStateMachine`` is exercised in isolation for the
|
||||||
|
bounded-delta clauses (AC-9..AC-11, AC-15) to keep the iSAM2
|
||||||
|
machinery out of the gate-only tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from unittest import mock
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard._types.fc import GpsSample
|
||||||
|
from gps_denied_onboard._types.geo import LatLonAlt
|
||||||
|
from gps_denied_onboard._types.pose import (
|
||||||
|
CovarianceMode,
|
||||||
|
PoseEstimate,
|
||||||
|
Quat,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard._types.state import PoseSourceLabel
|
||||||
|
from gps_denied_onboard._types.vio import VioOutput
|
||||||
|
from gps_denied_onboard.components.c5_state import (
|
||||||
|
C5StateConfig,
|
||||||
|
EstimatorAlreadyStartedError,
|
||||||
|
StateEstimator,
|
||||||
|
StateEstimatorConfigError,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c5_state._source_label_sm import (
|
||||||
|
BOUNDED_DELTA_REJECT,
|
||||||
|
BOUNDED_DELTA_SOFT,
|
||||||
|
SourceLabelStateMachine,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c5_state.eskf_baseline import EskfStateEstimator
|
||||||
|
from gps_denied_onboard.components.c5_state.gtsam_isam2_estimator import (
|
||||||
|
GtsamIsam2StateEstimator,
|
||||||
|
create as create_isam2,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.fdr_client.records import FdrRecord, parse, serialise
|
||||||
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||||
|
from gps_denied_onboard.runtime_root.state_factory import clear_state_registry
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Fixtures + builders.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _registry_isolation() -> Any:
|
||||||
|
clear_state_registry()
|
||||||
|
yield
|
||||||
|
clear_state_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_isam2(**overrides: Any) -> GtsamIsam2StateEstimator:
|
||||||
|
block = C5StateConfig(strategy="gtsam_isam2", keyframe_window_size=15, **overrides)
|
||||||
|
cfg = mock.MagicMock()
|
||||||
|
cfg.components = {"c5_state": block}
|
||||||
|
estimator, _ = create_isam2(
|
||||||
|
config=cfg,
|
||||||
|
imu_preintegrator=mock.MagicMock(),
|
||||||
|
se3_utils=mock.MagicMock(),
|
||||||
|
wgs_converter=mock.MagicMock(),
|
||||||
|
fdr_client=mock.MagicMock(),
|
||||||
|
)
|
||||||
|
return estimator
|
||||||
|
|
||||||
|
|
||||||
|
def _build_eskf(**overrides: Any) -> EskfStateEstimator:
|
||||||
|
block = C5StateConfig(strategy="eskf", keyframe_window_size=15, **overrides)
|
||||||
|
cfg = mock.MagicMock()
|
||||||
|
cfg.components = {"c5_state": block}
|
||||||
|
return EskfStateEstimator(
|
||||||
|
cfg,
|
||||||
|
imu_preintegrator=mock.MagicMock(),
|
||||||
|
se3_utils=mock.MagicMock(),
|
||||||
|
wgs_converter=mock.MagicMock(),
|
||||||
|
fdr_client=mock.MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin() -> LatLonAlt:
|
||||||
|
return LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _vio(*, frame_id: int, t_seconds: float) -> VioOutput:
|
||||||
|
return VioOutput(
|
||||||
|
frame_id=frame_id,
|
||||||
|
timestamp=datetime.fromtimestamp(t_seconds, tz=timezone.utc),
|
||||||
|
pose_se3=np.eye(4),
|
||||||
|
covariance_6x6=np.eye(6) * 0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_anchor(*, frame_id: int, t_seconds: float) -> PoseEstimate:
|
||||||
|
return PoseEstimate(
|
||||||
|
frame_id=UUID(int=frame_id),
|
||||||
|
position_wgs84=_origin(),
|
||||||
|
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
||||||
|
covariance_6x6=np.eye(6) * 0.01,
|
||||||
|
covariance_mode=CovarianceMode.MARGINALS,
|
||||||
|
source_label=PoseSourceLabel.SATELLITE_ANCHORED,
|
||||||
|
last_satellite_anchor_age_ms=0,
|
||||||
|
emitted_at=int(t_seconds * 1_000_000_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cold_start_records(estimator: GtsamIsam2StateEstimator | EskfStateEstimator) -> list[FdrRecord]:
|
||||||
|
enqueued = estimator._fdr_client.enqueue.call_args_list # type: ignore[union-attr]
|
||||||
|
out = []
|
||||||
|
for call in enqueued:
|
||||||
|
args, _ = call
|
||||||
|
if not args:
|
||||||
|
continue
|
||||||
|
record = args[0]
|
||||||
|
if isinstance(record, FdrRecord) and record.kind == "c5.cold_start_origin.set":
|
||||||
|
out.append(record)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_delta_records(
|
||||||
|
fdr: mock.MagicMock, *, kind: str
|
||||||
|
) -> list[FdrRecord]:
|
||||||
|
out = []
|
||||||
|
for call in fdr.enqueue.call_args_list:
|
||||||
|
args, _ = call
|
||||||
|
if args and isinstance(args[0], FdrRecord) and args[0].kind == kind:
|
||||||
|
out.append(args[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
"""Mutable monotonic clock used by SourceLabelStateMachine in tests."""
|
||||||
|
|
||||||
|
def __init__(self, t0: int = 0) -> None:
|
||||||
|
self.t = t0
|
||||||
|
|
||||||
|
def __call__(self) -> int:
|
||||||
|
return self.t
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sm(
|
||||||
|
*,
|
||||||
|
bounded_delta_m: float = 200.0,
|
||||||
|
fdr_client: mock.MagicMock | None = None,
|
||||||
|
clock: _Clock | None = None,
|
||||||
|
) -> tuple[SourceLabelStateMachine, mock.MagicMock]:
|
||||||
|
fdr = fdr_client if fdr_client is not None else mock.MagicMock()
|
||||||
|
sm = SourceLabelStateMachine(
|
||||||
|
spoof_promotion_min_stable_s=10.0,
|
||||||
|
spoof_promotion_visual_consistency_tol_m=30.0,
|
||||||
|
spoof_promotion_bounded_delta_m=bounded_delta_m,
|
||||||
|
fdr_client=fdr,
|
||||||
|
producer_id="c5_state",
|
||||||
|
clock_ns=clock if clock is not None else _Clock(0),
|
||||||
|
)
|
||||||
|
return sm, fdr
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-1: Protocol conformance — both impls expose set_takeoff_origin.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_isam2_is_state_estimator() -> None:
|
||||||
|
estimator = _build_isam2()
|
||||||
|
assert isinstance(estimator, StateEstimator)
|
||||||
|
assert callable(estimator.set_takeoff_origin)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_eskf_is_state_estimator() -> None:
|
||||||
|
estimator = _build_eskf()
|
||||||
|
assert isinstance(estimator, StateEstimator)
|
||||||
|
assert callable(estimator.set_takeoff_origin)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-2: iSAM2 happy path — origin seeds the smoother prior.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac2_isam2_set_takeoff_origin_seeds_prior_and_emits_fdr() -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = _build_isam2()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
# Assert — exactly one PriorFactorPose3 was committed
|
||||||
|
import gtsam
|
||||||
|
|
||||||
|
factors = estimator._isam2.getFactorsUnsafe()
|
||||||
|
prior_factors = [
|
||||||
|
factors.at(i)
|
||||||
|
for i in range(factors.size())
|
||||||
|
if isinstance(factors.at(i), gtsam.PriorFactorPose3)
|
||||||
|
]
|
||||||
|
assert len(prior_factors) == 1
|
||||||
|
# Pose is at Identity (origin == ENU(0,0,0))
|
||||||
|
pose = prior_factors[0].prior()
|
||||||
|
assert np.allclose(np.asarray(pose.translation()), np.zeros(3), atol=1e-9)
|
||||||
|
|
||||||
|
# Assert — exactly one cold-start FDR record with source="manifest"
|
||||||
|
records = _cold_start_records(estimator)
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].payload["source"] == "manifest"
|
||||||
|
assert records[0].payload["lat_deg"] == _origin().lat_deg
|
||||||
|
assert records[0].payload["sigma_horiz_m"] == 5.0
|
||||||
|
assert records[0].payload["sigma_vert_m"] == 10.0
|
||||||
|
|
||||||
|
# Assert — ENU origin tracks the operator origin
|
||||||
|
assert estimator._enu_origin == _origin()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-3: ESKF happy path — nominal state + P block seeded.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_eskf_set_takeoff_origin_seeds_state_and_p() -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = _build_eskf()
|
||||||
|
|
||||||
|
# Act
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
# Assert — nominal position is zero (origin IS the ENU(0,0,0) anchor)
|
||||||
|
assert np.allclose(estimator._nominal_pos, np.zeros(3), atol=1e-12)
|
||||||
|
# Assert — position block of P matches diag(25, 25, 100)
|
||||||
|
expected = np.diag([25.0, 25.0, 100.0])
|
||||||
|
assert np.allclose(estimator._P[0:3, 0:3], expected, atol=1e-12)
|
||||||
|
# Assert — ENU origin tracks the operator origin
|
||||||
|
assert estimator._enu_origin == _origin()
|
||||||
|
# Assert — exactly one FDR record
|
||||||
|
records = _cold_start_records(estimator)
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].payload["source"] == "manifest"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-4: Idempotent — calling twice with identical args is a no-op.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac4_idempotent_double_call_is_noop(builder: Any) -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = builder()
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
fdr_calls_before = len(estimator._fdr_client.enqueue.call_args_list)
|
||||||
|
|
||||||
|
# Act — second identical call
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
# Assert — no extra FDR record, no exception
|
||||||
|
fdr_calls_after = len(estimator._fdr_client.enqueue.call_args_list)
|
||||||
|
assert fdr_calls_after == fdr_calls_before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-5: Conflict — calling twice with different args raises.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac5_conflict_double_call_raises(builder: Any) -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = builder()
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
# Act + Assert — different origin
|
||||||
|
different = LatLonAlt(lat_deg=51.0, lon_deg=36.2, alt_m=200.0)
|
||||||
|
with pytest.raises(StateEstimatorConfigError) as excinfo:
|
||||||
|
estimator.set_takeoff_origin(different, sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
msg = str(excinfo.value)
|
||||||
|
# Both old and new origins named in the message
|
||||||
|
assert "previous=" in msg and "new=" in msg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-6: Late call — set_takeoff_origin after first add_vio raises.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_isam2_late_call_raises_already_started() -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = _build_isam2()
|
||||||
|
estimator._isam2_handle = mock.MagicMock() # stub the handle for add_vio
|
||||||
|
|
||||||
|
# Act — first add_vio closes the cold-start window
|
||||||
|
estimator.add_vio(_vio(frame_id=1, t_seconds=1.0))
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(EstimatorAlreadyStartedError):
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_eskf_late_call_raises_already_started() -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = _build_eskf()
|
||||||
|
estimator.add_vio(_vio(frame_id=1, t_seconds=1.0))
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
with pytest.raises(EstimatorAlreadyStartedError):
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-7: Out-of-bounds latitude raises StateEstimatorConfigError.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac7_invalid_latlonalt_raises(builder: Any) -> None:
|
||||||
|
# Arrange
|
||||||
|
estimator = builder()
|
||||||
|
bad_origin = LatLonAlt(lat_deg=95.0, lon_deg=36.0, alt_m=200.0)
|
||||||
|
|
||||||
|
# Act + Assert
|
||||||
|
with pytest.raises(StateEstimatorConfigError, match=r"latitude 95\.0 outside"):
|
||||||
|
estimator.set_takeoff_origin(bad_origin, sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac7_invalid_longitude_raises(builder: Any) -> None:
|
||||||
|
estimator = builder()
|
||||||
|
bad = LatLonAlt(lat_deg=50.0, lon_deg=200.0, alt_m=200.0)
|
||||||
|
with pytest.raises(StateEstimatorConfigError, match=r"longitude 200\.0 outside"):
|
||||||
|
estimator.set_takeoff_origin(bad, sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac7_non_finite_lat_raises(builder: Any) -> None:
|
||||||
|
estimator = builder()
|
||||||
|
bad = LatLonAlt(lat_deg=float("nan"), lon_deg=36.0, alt_m=200.0)
|
||||||
|
with pytest.raises(StateEstimatorConfigError, match="non-finite"):
|
||||||
|
estimator.set_takeoff_origin(bad, sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-8: Negative sigma raises.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac8_negative_horiz_sigma_raises(builder: Any) -> None:
|
||||||
|
estimator = builder()
|
||||||
|
with pytest.raises(StateEstimatorConfigError, match="sigma_horiz_m must be positive"):
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=-5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("builder", [_build_isam2, _build_eskf])
|
||||||
|
def test_ac8_zero_vert_sigma_raises(builder: Any) -> None:
|
||||||
|
estimator = builder()
|
||||||
|
with pytest.raises(StateEstimatorConfigError, match="sigma_vert_m must be positive"):
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-9: Bounded-delta accept — sample within ring is admitted.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac9_bounded_delta_accept_emits_soft_label() -> None:
|
||||||
|
# Arrange
|
||||||
|
sm, fdr = _make_sm(bounded_delta_m=200.0)
|
||||||
|
smoother_estimate = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||||
|
sample = LatLonAlt(lat_deg=50.0008, lon_deg=36.2008, alt_m=200.0)
|
||||||
|
|
||||||
|
# Sanity — distance is well under the ring (≈ 100 m at 50° N).
|
||||||
|
distance = WgsConverter.horizontal_distance_m(smoother_estimate, sample)
|
||||||
|
assert distance < 200.0
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = sm.process_gps_sample(sample, smoother_estimate=smoother_estimate)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result == BOUNDED_DELTA_SOFT
|
||||||
|
accepts = _bounded_delta_records(fdr, kind="c5.gps_bounded_delta.accept")
|
||||||
|
assert len(accepts) == 1
|
||||||
|
assert accepts[0].payload["distance_m"] == pytest.approx(distance, rel=1e-9)
|
||||||
|
assert accepts[0].payload["threshold_m"] == 200.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-10: Bounded-delta reject — sample outside ring is dropped.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac10_bounded_delta_reject_emits_record_and_resets_dwell() -> None:
|
||||||
|
# Arrange
|
||||||
|
sm, fdr = _make_sm(bounded_delta_m=200.0)
|
||||||
|
smoother_estimate = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||||
|
sample = LatLonAlt(lat_deg=50.005, lon_deg=36.205, alt_m=200.0)
|
||||||
|
distance = WgsConverter.horizontal_distance_m(smoother_estimate, sample)
|
||||||
|
assert distance > 200.0
|
||||||
|
|
||||||
|
# Seed a STABLE_NON_SPOOFED dwell so we can observe its reset
|
||||||
|
from gps_denied_onboard._types.fc import GpsHealth, GpsStatus
|
||||||
|
|
||||||
|
sm.notify_gps_health(
|
||||||
|
GpsHealth(status=GpsStatus.STABLE_NON_SPOOFED, fix_age_ms=10, captured_at=0)
|
||||||
|
)
|
||||||
|
assert sm._gps_health_stable_since_ns is not None # arrange precondition
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = sm.process_gps_sample(sample, smoother_estimate=smoother_estimate)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result == BOUNDED_DELTA_REJECT
|
||||||
|
rejects = _bounded_delta_records(fdr, kind="c5.gps_bounded_delta.reject")
|
||||||
|
assert len(rejects) == 1
|
||||||
|
rec = rejects[0].payload
|
||||||
|
assert rec["sample_lat"] == sample.lat_deg
|
||||||
|
assert rec["smoother_lat"] == smoother_estimate.lat_deg
|
||||||
|
assert rec["distance_m"] == pytest.approx(distance, rel=1e-9)
|
||||||
|
# Dwell-time clause was reset by the reject
|
||||||
|
assert sm._gps_health_stable_since_ns is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-11: Threshold is config-driven — relaxing it admits AC-10's sample.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac11_threshold_relaxed_admits_previously_rejected_sample() -> None:
|
||||||
|
sm, _fdr = _make_sm(bounded_delta_m=1000.0)
|
||||||
|
smoother_estimate = LatLonAlt(lat_deg=50.0, lon_deg=36.2, alt_m=200.0)
|
||||||
|
sample = LatLonAlt(lat_deg=50.005, lon_deg=36.205, alt_m=200.0)
|
||||||
|
|
||||||
|
result = sm.process_gps_sample(sample, smoother_estimate=smoother_estimate)
|
||||||
|
assert result == BOUNDED_DELTA_SOFT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-12: FDR record kinds round-trip through the schema.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "payload"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"c5.cold_start_origin.set",
|
||||||
|
{
|
||||||
|
"source": "manifest",
|
||||||
|
"lat_deg": 50.0,
|
||||||
|
"lon_deg": 36.2,
|
||||||
|
"alt_m": 200.0,
|
||||||
|
"sigma_horiz_m": 5.0,
|
||||||
|
"sigma_vert_m": 10.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"c5.cold_start_origin.unavailable",
|
||||||
|
{"reason": "no_manifest_origin_no_fc_ekf"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"c5.gps_bounded_delta.accept",
|
||||||
|
{
|
||||||
|
"sample_lat": 50.0,
|
||||||
|
"sample_lon": 36.2,
|
||||||
|
"smoother_lat": 50.0001,
|
||||||
|
"smoother_lon": 36.2,
|
||||||
|
"distance_m": 11.1,
|
||||||
|
"threshold_m": 200.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"c5.gps_bounded_delta.reject",
|
||||||
|
{
|
||||||
|
"sample_lat": 50.0,
|
||||||
|
"sample_lon": 36.2,
|
||||||
|
"smoother_lat": 50.005,
|
||||||
|
"smoother_lon": 36.205,
|
||||||
|
"distance_m": 700.0,
|
||||||
|
"threshold_m": 200.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac12_fdr_kinds_round_trip(kind: str, payload: dict[str, Any]) -> None:
|
||||||
|
record = FdrRecord(
|
||||||
|
schema_version=1,
|
||||||
|
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||||
|
producer_id="c5_state",
|
||||||
|
kind=kind,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
encoded = serialise(record)
|
||||||
|
decoded = parse(encoded)
|
||||||
|
|
||||||
|
assert decoded.kind == kind
|
||||||
|
assert decoded.payload == payload
|
||||||
|
# No unknown-key bucket for the AZ-490 shape (every key is registered)
|
||||||
|
assert "extra" not in decoded.payload
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-13: No origin → FC-EKF cold-start path emits one fc_ekf record.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac13_isam2_no_origin_emits_fc_ekf_record_on_first_add() -> None:
|
||||||
|
estimator = _build_isam2()
|
||||||
|
estimator._isam2_handle = mock.MagicMock()
|
||||||
|
|
||||||
|
estimator.add_vio(_vio(frame_id=1, t_seconds=1.0))
|
||||||
|
estimator.add_vio(_vio(frame_id=2, t_seconds=2.0))
|
||||||
|
|
||||||
|
records = _cold_start_records(estimator)
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].payload["source"] == "fc_ekf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac13_eskf_no_origin_emits_fc_ekf_record_on_first_add() -> None:
|
||||||
|
estimator = _build_eskf()
|
||||||
|
|
||||||
|
estimator.add_vio(_vio(frame_id=1, t_seconds=1.0))
|
||||||
|
estimator.add_vio(_vio(frame_id=2, t_seconds=2.0))
|
||||||
|
|
||||||
|
records = _cold_start_records(estimator)
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].payload["source"] == "fc_ekf"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-14: AZ-385's first two clauses are unchanged — bounded-delta is additive.
|
||||||
|
#
|
||||||
|
# We re-run the spoof-gate engagement / gate-lift path WITHOUT touching
|
||||||
|
# the bounded-delta method and assert the canonical gate behaviour
|
||||||
|
# still holds. The full AZ-385 acceptance suite re-runs in
|
||||||
|
# test_az385_source_label_spoof_gate.py — this test is the smoke
|
||||||
|
# check that the new clause did not perturb the existing wiring.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac14_existing_spoof_gate_unchanged_by_new_clause() -> None:
|
||||||
|
from gps_denied_onboard._types.fc import GpsHealth, GpsStatus
|
||||||
|
|
||||||
|
clock = _Clock(0)
|
||||||
|
sm, _ = _make_sm(bounded_delta_m=200.0, clock=clock)
|
||||||
|
|
||||||
|
# Initial — DEAD_RECKONED until first anchor
|
||||||
|
assert sm.current_label() == PoseSourceLabel.DEAD_RECKONED
|
||||||
|
|
||||||
|
# Anchor without a spoof event → SATELLITE_ANCHORED
|
||||||
|
sm.notify_gps_health(
|
||||||
|
GpsHealth(status=GpsStatus.STABLE_NON_SPOOFED, fix_age_ms=10, captured_at=0)
|
||||||
|
)
|
||||||
|
clock.t = 1_000_000_000 # 1 s in
|
||||||
|
sm.notify_satellite_anchor(now_ns=clock.t, gps_consistency_delta_m=1.0)
|
||||||
|
assert sm.current_label() == PoseSourceLabel.SATELLITE_ANCHORED
|
||||||
|
|
||||||
|
# Spoof event → gate latches closed → VISUAL_PROPAGATED
|
||||||
|
sm.notify_gps_health(
|
||||||
|
GpsHealth(status=GpsStatus.SPOOFED, fix_age_ms=10, captured_at=0)
|
||||||
|
)
|
||||||
|
assert sm.is_spoof_promotion_blocked() is True
|
||||||
|
assert sm.current_label() == PoseSourceLabel.VISUAL_PROPAGATED
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# AC-15: Distance is computed via WgsConverter (geodetic), not haversine
|
||||||
|
# on the equirectangular projection.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac15_geodetic_distance_at_60n_within_half_meter() -> None:
|
||||||
|
"""AC-15 — distance computed via WgsConverter agrees with the WGS-84
|
||||||
|
geodesic (pyproj.Geod inverse) within 0.5 m at 60° N for a ~200 m
|
||||||
|
east-west offset.
|
||||||
|
|
||||||
|
The ground truth is the WGS-84 ellipsoid Vincenty distance from
|
||||||
|
pyproj's Geod, NOT a haversine-on-equirectangular shortcut — the
|
||||||
|
AC explicitly excludes the latter shortcut.
|
||||||
|
"""
|
||||||
|
from pyproj import Geod
|
||||||
|
|
||||||
|
origin = LatLonAlt(lat_deg=60.0, lon_deg=10.0, alt_m=0.0)
|
||||||
|
# Construct a sample that is approximately 200 m due east using the
|
||||||
|
# spherical estimate; the test only requires agreement with the
|
||||||
|
# ellipsoid Vincenty distance, NOT 200 m exactly.
|
||||||
|
earth_radius_m = 6_378_137.0
|
||||||
|
metres_per_degree_lon = (
|
||||||
|
math.pi * earth_radius_m * math.cos(math.radians(60.0)) / 180.0
|
||||||
|
)
|
||||||
|
sample = LatLonAlt(
|
||||||
|
lat_deg=60.0,
|
||||||
|
lon_deg=10.0 + 200.0 / metres_per_degree_lon,
|
||||||
|
alt_m=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
geod = Geod(ellps="WGS84")
|
||||||
|
_az_fwd, _az_back, ground_truth_m = geod.inv(
|
||||||
|
origin.lon_deg, origin.lat_deg, sample.lon_deg, sample.lat_deg
|
||||||
|
)
|
||||||
|
|
||||||
|
distance = WgsConverter.horizontal_distance_m(origin, sample)
|
||||||
|
|
||||||
|
assert abs(distance - ground_truth_m) < 0.5
|
||||||
|
# Symmetry — Principle of geodesic invariance.
|
||||||
|
distance_reverse = WgsConverter.horizontal_distance_m(sample, origin)
|
||||||
|
assert abs(distance - distance_reverse) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Bounded-delta gate ignores no-smoother case.
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_delta_no_smoother_returns_none() -> None:
|
||||||
|
sm, fdr = _make_sm(bounded_delta_m=200.0)
|
||||||
|
sample = LatLonAlt(lat_deg=50.0, lon_deg=36.0, alt_m=200.0)
|
||||||
|
|
||||||
|
result = sm.process_gps_sample(sample, smoother_estimate=None)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
# No FDR record either way
|
||||||
|
assert fdr.enqueue.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Estimator.notify_gps_sample — happy-path delegation.
|
||||||
|
|
||||||
|
|
||||||
|
def test_eskf_notify_gps_sample_delegates_to_state_machine() -> None:
|
||||||
|
# Arrange — seed an operator origin so the smoother latlon is real
|
||||||
|
estimator = _build_eskf()
|
||||||
|
estimator.set_takeoff_origin(_origin(), sigma_horiz_m=5.0, sigma_vert_m=10.0)
|
||||||
|
|
||||||
|
# Sample within the 200 m ring of the origin
|
||||||
|
sample = GpsSample(
|
||||||
|
position_wgs84=LatLonAlt(
|
||||||
|
lat_deg=_origin().lat_deg + 0.0008,
|
||||||
|
lon_deg=_origin().lon_deg + 0.0008,
|
||||||
|
alt_m=200.0,
|
||||||
|
),
|
||||||
|
captured_at=1_000_000_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
result = estimator.notify_gps_sample(sample)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert result == BOUNDED_DELTA_SOFT
|
||||||
|
accepts = _bounded_delta_records(
|
||||||
|
estimator._fdr_client, kind="c5.gps_bounded_delta.accept"
|
||||||
|
)
|
||||||
|
assert len(accepts) == 1
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"""AZ-303 — C6 storage Protocol + DTO + error + factory conformance.
|
||||||
|
|
||||||
|
Covers all 10 ACs of AZ-303 (see ``_docs/02_tasks/todo/AZ-303_...``).
|
||||||
|
|
||||||
|
The factory ACs (AC-4 / AC-5) substitute lazy-importable fake impl
|
||||||
|
modules at ``sys.modules`` boundaries so the test never touches FAISS
|
||||||
|
or psycopg2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache import (
|
||||||
|
Bbox,
|
||||||
|
C6TileCacheConfig,
|
||||||
|
ContentHashMismatchError,
|
||||||
|
DescriptorIndex,
|
||||||
|
FreshnessLabel,
|
||||||
|
FreshnessRejectionError,
|
||||||
|
HnswParams,
|
||||||
|
IndexBuildError,
|
||||||
|
IndexMetadata,
|
||||||
|
IndexUnavailableError,
|
||||||
|
SectorBoundary,
|
||||||
|
SectorClassification,
|
||||||
|
TileCacheError,
|
||||||
|
TileFsError,
|
||||||
|
TileId,
|
||||||
|
TileMetadata,
|
||||||
|
TileMetadataError,
|
||||||
|
TileMetadataPersistent,
|
||||||
|
TileMetadataStore,
|
||||||
|
TileNotFoundError,
|
||||||
|
TilePixelHandle,
|
||||||
|
TileQualityMetadata,
|
||||||
|
TileSource,
|
||||||
|
TileStore,
|
||||||
|
VotingStatus,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c6_tile_cache.config import (
|
||||||
|
KNOWN_DESCRIPTOR_INDEX_RUNTIMES,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.config.schema import Config, ConfigError
|
||||||
|
from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError
|
||||||
|
from gps_denied_onboard.runtime_root.storage_factory import (
|
||||||
|
build_descriptor_index,
|
||||||
|
build_tile_metadata_store,
|
||||||
|
build_tile_store,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTRACT_DIR = Path(__file__).resolve().parents[3] / (
|
||||||
|
"_docs/02_document/contracts/c6_tile_cache"
|
||||||
|
)
|
||||||
|
_FAKE_IMPL_MODULE = "gps_denied_onboard.components.c6_tile_cache.faiss_descriptor_index"
|
||||||
|
_FAKE_STORE_MODULE = (
|
||||||
|
"gps_denied_onboard.components.c6_tile_cache.postgres_filesystem_store"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_tile_id(zoom: int = 18, lat: float = 49.94, lon: float = 36.31) -> TileId:
|
||||||
|
return TileId(zoom_level=zoom, lat=lat, lon=lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_tile_metadata() -> TileMetadata:
|
||||||
|
return TileMetadata(
|
||||||
|
tile_id=_valid_tile_id(),
|
||||||
|
tile_size_meters=256.0,
|
||||||
|
tile_size_pixels=256,
|
||||||
|
capture_timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
source=TileSource.GOOGLEMAPS,
|
||||||
|
content_sha256_hex="a" * 64,
|
||||||
|
freshness_label=FreshnessLabel.FRESH,
|
||||||
|
flight_id=None,
|
||||||
|
companion_id=None,
|
||||||
|
quality_metadata=None,
|
||||||
|
voting_status=VotingStatus.TRUSTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_with_c6(overrides: dict[str, object] | None = None) -> Config:
|
||||||
|
overrides = overrides or {}
|
||||||
|
block = C6TileCacheConfig(**overrides) # type: ignore[arg-type]
|
||||||
|
return Config.with_blocks(c6_tile_cache=block)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-1: three Protocols are conformance-checkable.
|
||||||
|
|
||||||
|
|
||||||
|
class _FullTileStore:
|
||||||
|
def read_tile_pixels(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def write_tile(self, tile_blob, metadata):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def tile_exists(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def delete_tile(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _PartialTileStore:
|
||||||
|
def read_tile_pixels(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def write_tile(self, tile_blob, metadata):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def tile_exists(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _FullTileMetadataStore:
|
||||||
|
def query_by_bbox(self, bbox, zoom, *, voting_filter=None, source_filter=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def insert_metadata(self, metadata):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update_voting_status(self, tile_id, status):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def mark_uploaded(self, tile_id, uploaded_at):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def pending_uploads(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def record_lru_access(self, tile_id, accessed_at):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def lru_candidates(self, *, max_count):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def total_disk_bytes(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_by_id(self, tile_id):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _PartialTileMetadataStore:
|
||||||
|
def query_by_bbox(self, bbox, zoom, *, voting_filter=None, source_filter=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _FullDescriptorIndex:
|
||||||
|
def search_topk(self, query, k):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def descriptor_dim(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def mmap_handle(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def rebuild_from_descriptors(self, descriptors, tile_ids, hnsw_params):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def index_metadata(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _PartialDescriptorIndex:
|
||||||
|
def search_topk(self, query, k):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def descriptor_dim(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_tile_store_conformance_full() -> None:
|
||||||
|
assert isinstance(_FullTileStore(), TileStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_tile_store_conformance_partial_missing_delete() -> None:
|
||||||
|
assert not isinstance(_PartialTileStore(), TileStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_tile_metadata_store_conformance_full() -> None:
|
||||||
|
assert isinstance(_FullTileMetadataStore(), TileMetadataStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_tile_metadata_store_conformance_partial() -> None:
|
||||||
|
assert not isinstance(_PartialTileMetadataStore(), TileMetadataStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_descriptor_index_conformance_full() -> None:
|
||||||
|
assert isinstance(_FullDescriptorIndex(), DescriptorIndex)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_descriptor_index_conformance_partial_missing_metadata() -> None:
|
||||||
|
assert not isinstance(_PartialDescriptorIndex(), DescriptorIndex)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-2: frozen DTOs reject mutation.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dto, field_name, new_value",
|
||||||
|
[
|
||||||
|
(_valid_tile_id(), "lat", 0.0),
|
||||||
|
(_valid_tile_metadata(), "tile_size_meters", 9999.0),
|
||||||
|
(Bbox(min_lat=0.0, min_lon=0.0, max_lat=1.0, max_lon=1.0), "min_lat", 5.0),
|
||||||
|
(HnswParams(), "m", 64),
|
||||||
|
(
|
||||||
|
TileQualityMetadata(
|
||||||
|
estimator_label="satellite_anchored",
|
||||||
|
covariance_2x2=((0.1, 0.0), (0.0, 0.1)),
|
||||||
|
last_anchor_age_ms=100,
|
||||||
|
mre_px=0.5,
|
||||||
|
imu_bias_norm=0.01,
|
||||||
|
),
|
||||||
|
"mre_px",
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac2_frozen_dtos_reject_mutation(dto, field_name: str, new_value) -> None:
|
||||||
|
original_value = getattr(dto, field_name)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
setattr(dto, field_name, new_value)
|
||||||
|
assert getattr(dto, field_name) == original_value
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-3: error hierarchy catchable as a single family.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exc_factory",
|
||||||
|
[
|
||||||
|
TileNotFoundError,
|
||||||
|
TileFsError,
|
||||||
|
TileMetadataError,
|
||||||
|
ContentHashMismatchError,
|
||||||
|
FreshnessRejectionError,
|
||||||
|
IndexUnavailableError,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac3_all_runtime_errors_caught_as_family(exc_factory) -> None:
|
||||||
|
with pytest.raises(TileCacheError):
|
||||||
|
raise exc_factory("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_unrelated_exception_not_caught_as_family() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
try:
|
||||||
|
raise ValueError("not us")
|
||||||
|
except TileCacheError:
|
||||||
|
pytest.fail("ValueError must not be caught as TileCacheError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_index_build_error_outside_family() -> None:
|
||||||
|
with pytest.raises(IndexBuildError):
|
||||||
|
try:
|
||||||
|
raise IndexBuildError("offline only")
|
||||||
|
except TileCacheError:
|
||||||
|
pytest.fail("IndexBuildError must NOT be in the TileCacheError family")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-4 + AC-5: factory honours config + BUILD flag gate.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def faiss_module_cleanup():
|
||||||
|
"""Ensure no residual fake FAISS impl module leaks between tests."""
|
||||||
|
sys.modules.pop(_FAKE_IMPL_MODULE, None)
|
||||||
|
yield
|
||||||
|
sys.modules.pop(_FAKE_IMPL_MODULE, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_module_cleanup():
|
||||||
|
sys.modules.pop(_FAKE_STORE_MODULE, None)
|
||||||
|
yield
|
||||||
|
sys.modules.pop(_FAKE_STORE_MODULE, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_faiss_impl_module() -> type:
|
||||||
|
"""Install a fake ``faiss_descriptor_index`` module in ``sys.modules``.
|
||||||
|
|
||||||
|
The fake's ``FaissDescriptorIndex`` class structurally satisfies the
|
||||||
|
:class:`DescriptorIndex` Protocol. We attach the class via
|
||||||
|
``types.ModuleType`` so the factory's lazy import succeeds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class _FakeFaissDescriptorIndex(_FullDescriptorIndex):
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
fake_module = types.ModuleType(_FAKE_IMPL_MODULE)
|
||||||
|
fake_module.FaissDescriptorIndex = _FakeFaissDescriptorIndex # type: ignore[attr-defined]
|
||||||
|
sys.modules[_FAKE_IMPL_MODULE] = fake_module
|
||||||
|
return _FakeFaissDescriptorIndex
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_postgres_store_module() -> type:
|
||||||
|
class _FakePostgresFilesystemStore(_FullTileStore, _FullTileMetadataStore):
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
fake_module = types.ModuleType(_FAKE_STORE_MODULE)
|
||||||
|
fake_module.PostgresFilesystemStore = _FakePostgresFilesystemStore # type: ignore[attr-defined]
|
||||||
|
sys.modules[_FAKE_STORE_MODULE] = fake_module
|
||||||
|
return _FakePostgresFilesystemStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_build_descriptor_index_returns_protocol_impl(
|
||||||
|
monkeypatch, faiss_module_cleanup
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("BUILD_FAISS_INDEX", "ON")
|
||||||
|
fake_cls = _install_fake_faiss_impl_module()
|
||||||
|
config = _config_with_c6()
|
||||||
|
handle = build_descriptor_index(config)
|
||||||
|
assert isinstance(handle, fake_cls)
|
||||||
|
assert isinstance(handle, DescriptorIndex)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac5_build_descriptor_index_flag_off_raises_no_import(
|
||||||
|
monkeypatch, faiss_module_cleanup
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("BUILD_FAISS_INDEX", raising=False)
|
||||||
|
config = _config_with_c6()
|
||||||
|
with pytest.raises(RuntimeNotAvailableError) as exc_info:
|
||||||
|
build_descriptor_index(config)
|
||||||
|
assert "faiss_hnsw" in str(exc_info.value)
|
||||||
|
assert _FAKE_IMPL_MODULE not in sys.modules
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_build_tile_store_returns_protocol_impl(store_module_cleanup) -> None:
|
||||||
|
fake_cls = _install_fake_postgres_store_module()
|
||||||
|
config = _config_with_c6()
|
||||||
|
store = build_tile_store(config)
|
||||||
|
assert isinstance(store, fake_cls)
|
||||||
|
assert isinstance(store, TileStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac4_build_tile_metadata_store_returns_protocol_impl(
|
||||||
|
store_module_cleanup,
|
||||||
|
) -> None:
|
||||||
|
fake_cls = _install_fake_postgres_store_module()
|
||||||
|
config = _config_with_c6()
|
||||||
|
md = build_tile_metadata_store(config)
|
||||||
|
assert isinstance(md, fake_cls)
|
||||||
|
assert isinstance(md, TileMetadataStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac5_tile_store_runtime_module_missing_raises(store_module_cleanup) -> None:
|
||||||
|
config = _config_with_c6()
|
||||||
|
with pytest.raises(RuntimeNotAvailableError) as exc_info:
|
||||||
|
build_tile_store(config)
|
||||||
|
assert "postgres_filesystem" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-6: unknown runtime label rejected at config load.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_unknown_descriptor_index_runtime_rejected() -> None:
|
||||||
|
with pytest.raises(ConfigError) as exc_info:
|
||||||
|
C6TileCacheConfig(descriptor_index_runtime="scann")
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert "scann" in msg
|
||||||
|
for valid in KNOWN_DESCRIPTOR_INDEX_RUNTIMES:
|
||||||
|
assert valid in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_unknown_store_runtime_rejected() -> None:
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
C6TileCacheConfig(store_runtime="sqlite_filesystem")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac6_unknown_metadata_runtime_rejected() -> None:
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
C6TileCacheConfig(metadata_runtime="sqlite_filesystem")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-7: constructor-time validation rejects bad input.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs, offending_field",
|
||||||
|
[
|
||||||
|
({"zoom_level": 22, "lat": 0.0, "lon": 0.0}, "zoom_level"),
|
||||||
|
({"zoom_level": -1, "lat": 0.0, "lon": 0.0}, "zoom_level"),
|
||||||
|
({"zoom_level": 18, "lat": 100.0, "lon": 0.0}, "lat"),
|
||||||
|
({"zoom_level": 18, "lat": 0.0, "lon": -200.0}, "lon"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac7_tile_id_rejects_bad_input(
|
||||||
|
kwargs: dict[str, float], offending_field: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
TileId(**kwargs) # type: ignore[arg-type]
|
||||||
|
assert offending_field in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs",
|
||||||
|
[
|
||||||
|
{"min_lat": 10.0, "min_lon": 0.0, "max_lat": 5.0, "max_lon": 10.0},
|
||||||
|
{"min_lat": 0.0, "min_lon": 10.0, "max_lat": 5.0, "max_lon": 5.0},
|
||||||
|
{"min_lat": 5.0, "min_lon": 5.0, "max_lat": 5.0, "max_lon": 10.0},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac7_bbox_rejects_inverted_or_degenerate(kwargs: dict[str, float]) -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Bbox(**kwargs) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-8: TilePixelHandle is read-only by contract.
|
||||||
|
|
||||||
|
|
||||||
|
class _BytesTilePixelHandle(TilePixelHandle):
|
||||||
|
"""In-memory fake used by tests; mirrors the mmap impl's read-only contract."""
|
||||||
|
|
||||||
|
def __init__(self, blob: bytes, path: Path) -> None:
|
||||||
|
self._blob = blob
|
||||||
|
self._path = path
|
||||||
|
self._view: memoryview | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filesystem_path(self) -> Path:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
def __enter__(self) -> memoryview:
|
||||||
|
self._view = memoryview(self._blob)
|
||||||
|
return self._view
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
if self._view is not None:
|
||||||
|
self._view.release()
|
||||||
|
self._view = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac8_tile_pixel_handle_returns_read_only_memoryview(tmp_path: Path) -> None:
|
||||||
|
blob = b"\xff\xd8\xff" + b"\x00" * 100 # JPEG SOI + filler
|
||||||
|
handle_path = tmp_path / "fake.jpg"
|
||||||
|
handle = _BytesTilePixelHandle(blob, handle_path)
|
||||||
|
with handle as memview:
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
memview[0] = 0xFF # type: ignore[index]
|
||||||
|
assert bytes(memview[:3]) == b"\xff\xd8\xff"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-9: contract files match Protocol shapes.
|
||||||
|
|
||||||
|
|
||||||
|
_METHOD_TABLE_RE = re.compile(r"^\|\s*`(?P<name>[a-z_][a-z0-9_]*)`\s*\|", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _methods_from_contract(contract_file: Path) -> set[str]:
|
||||||
|
"""Pull every backtick-quoted method name from the ``## Shape`` table."""
|
||||||
|
text = contract_file.read_text(encoding="utf-8")
|
||||||
|
shape_start = text.index("## Shape")
|
||||||
|
next_section = text.find("\n## ", shape_start + len("## Shape"))
|
||||||
|
shape_section = text[shape_start:next_section] if next_section != -1 else text[shape_start:]
|
||||||
|
return {m.group("name") for m in _METHOD_TABLE_RE.finditer(shape_section)}
|
||||||
|
|
||||||
|
|
||||||
|
def _protocol_methods(proto: type) -> set[str]:
|
||||||
|
"""Reflect over a Protocol's method names."""
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in dir(proto)
|
||||||
|
if not name.startswith("_") and callable(getattr(proto, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"contract_filename, proto",
|
||||||
|
[
|
||||||
|
("tile_store.md", TileStore),
|
||||||
|
("tile_metadata_store.md", TileMetadataStore),
|
||||||
|
("descriptor_index.md", DescriptorIndex),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac9_contract_methods_match_protocol(
|
||||||
|
contract_filename: str, proto: type
|
||||||
|
) -> None:
|
||||||
|
contract_path = _CONTRACT_DIR / contract_filename
|
||||||
|
contract_methods = _methods_from_contract(contract_path)
|
||||||
|
protocol_methods = _protocol_methods(proto)
|
||||||
|
missing_in_protocol = contract_methods - protocol_methods
|
||||||
|
missing_in_contract = protocol_methods - contract_methods
|
||||||
|
assert not missing_in_protocol, (
|
||||||
|
f"{contract_filename}: methods declared in contract but missing from "
|
||||||
|
f"Protocol: {sorted(missing_in_protocol)}"
|
||||||
|
)
|
||||||
|
assert not missing_in_contract, (
|
||||||
|
f"{contract_filename}: methods present on Protocol but missing from "
|
||||||
|
f"contract: {sorted(missing_in_contract)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-10: VotingStatus surface.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac10_voting_status_has_documented_states_only() -> None:
|
||||||
|
assert {v.value for v in VotingStatus} == {"pending", "trusted", "rejected"}
|
||||||
|
assert VotingStatus.PENDING.value == "pending"
|
||||||
|
assert VotingStatus.TRUSTED.value == "trusted"
|
||||||
|
assert VotingStatus.REJECTED.value == "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# NFR-reliability-error-family + smoke surface tests.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exc_type",
|
||||||
|
[
|
||||||
|
TileNotFoundError,
|
||||||
|
TileFsError,
|
||||||
|
TileMetadataError,
|
||||||
|
ContentHashMismatchError,
|
||||||
|
FreshnessRejectionError,
|
||||||
|
IndexUnavailableError,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_nfr_reliability_all_runtime_errors_subclass_family(exc_type) -> None:
|
||||||
|
assert issubclass(exc_type, TileCacheError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nfr_reliability_index_build_error_not_in_family() -> None:
|
||||||
|
assert not issubclass(IndexBuildError, TileCacheError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sector_classification_enum_surface() -> None:
|
||||||
|
assert {v.value for v in SectorClassification} == {
|
||||||
|
"active_conflict",
|
||||||
|
"stable_rear",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sector_boundary_dto_constructs_and_freezes() -> None:
|
||||||
|
bbox = Bbox(min_lat=0.0, min_lon=0.0, max_lat=1.0, max_lon=1.0)
|
||||||
|
sector = SectorBoundary(bbox=bbox, classification=SectorClassification.ACTIVE_CONFLICT)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
sector.classification = SectorClassification.STABLE_REAR # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tile_metadata_persistent_dto_constructs_and_freezes() -> None:
|
||||||
|
md = _valid_tile_metadata()
|
||||||
|
persistent = TileMetadataPersistent(
|
||||||
|
metadata=md,
|
||||||
|
accessed_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
|
||||||
|
uploaded_at=None,
|
||||||
|
disk_bytes=12345,
|
||||||
|
)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
persistent.disk_bytes = 0 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_nfr_perf_build_factories_under_50ms_p99(
|
||||||
|
monkeypatch, faiss_module_cleanup, store_module_cleanup
|
||||||
|
) -> None:
|
||||||
|
"""Factory triple p99 ≤ 50 ms across 1000 calls (NFR-perf-factory)."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
monkeypatch.setenv("BUILD_FAISS_INDEX", "ON")
|
||||||
|
_install_fake_faiss_impl_module()
|
||||||
|
_install_fake_postgres_store_module()
|
||||||
|
config = _config_with_c6()
|
||||||
|
|
||||||
|
durations_ms: list[float] = []
|
||||||
|
for _ in range(1000):
|
||||||
|
for factory in (build_tile_store, build_tile_metadata_store, build_descriptor_index):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
factory(config)
|
||||||
|
durations_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
|
||||||
|
durations_ms.sort()
|
||||||
|
p99 = durations_ms[int(0.99 * len(durations_ms))]
|
||||||
|
assert p99 <= 50.0, f"build_*() p99={p99:.3f} ms exceeds 50 ms NFR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_metadata_dto_constructs_and_freezes(tmp_path: Path) -> None:
|
||||||
|
md = IndexMetadata(
|
||||||
|
descriptor_dim=512,
|
||||||
|
n_vectors=10_000,
|
||||||
|
backbone_label="ultra_vpr_v0",
|
||||||
|
backbone_sha256_hex="b" * 64,
|
||||||
|
built_at=datetime(2026, 1, 3, tzinfo=timezone.utc),
|
||||||
|
hnsw_params=HnswParams(),
|
||||||
|
sidecar_sha256_hex="c" * 64,
|
||||||
|
file_path=tmp_path / "tiles.index",
|
||||||
|
)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
md.n_vectors = 0 # type: ignore[misc]
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""C6 TileCache smoke test — AC-9."""
|
|
||||||
|
|
||||||
|
|
||||||
def test_interface_importable() -> None:
|
|
||||||
# Assert
|
|
||||||
from gps_denied_onboard.components.c6_tile_cache import (
|
|
||||||
DescriptorIndex,
|
|
||||||
SectorClassification,
|
|
||||||
Tile,
|
|
||||||
TileQualityMetadata,
|
|
||||||
TileRecord,
|
|
||||||
TileStore,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert TileStore is not None
|
|
||||||
assert DescriptorIndex is not None
|
|
||||||
for cls in (Tile, TileQualityMetadata, TileRecord, SectorClassification):
|
|
||||||
assert cls is not None
|
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
"""AZ-297 — C7 inference runtime Protocol + DTO + error + factory conformance.
|
||||||
|
|
||||||
|
Covers all 8 ACs of AZ-297 plus NFR-perf-factory and
|
||||||
|
NFR-reliability-error-family. The factory ACs (AC-4 / AC-5) substitute
|
||||||
|
fake strategy modules at ``sys.modules`` boundaries so the test never
|
||||||
|
touches TensorRT, ONNX Runtime, or PyTorch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gps_denied_onboard.components.c7_inference import (
|
||||||
|
BuildConfig,
|
||||||
|
C7InferenceConfig,
|
||||||
|
CalibrationCacheError,
|
||||||
|
EngineBuildError,
|
||||||
|
EngineCacheEntry,
|
||||||
|
EngineDeserializeError,
|
||||||
|
EngineHandle,
|
||||||
|
EngineHashMismatchError,
|
||||||
|
EngineSchemaMismatchError,
|
||||||
|
EngineSidecarMissingError,
|
||||||
|
InferenceError,
|
||||||
|
InferenceRuntime,
|
||||||
|
OptimizationProfile,
|
||||||
|
OutOfMemoryError,
|
||||||
|
PrecisionMode,
|
||||||
|
RuntimeError as C7RuntimeError,
|
||||||
|
TelemetryUnavailableError,
|
||||||
|
ThermalState,
|
||||||
|
)
|
||||||
|
from gps_denied_onboard.components.c7_inference.config import KNOWN_RUNTIMES
|
||||||
|
from gps_denied_onboard.config.schema import Config, ConfigError
|
||||||
|
from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError
|
||||||
|
from gps_denied_onboard.runtime_root.inference_factory import (
|
||||||
|
build_inference_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CONTRACT_PATH = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "_docs/02_document/contracts/c7_inference/inference_runtime_protocol.md"
|
||||||
|
)
|
||||||
|
_STRATEGY_MODULES: dict[str, tuple[str, str, str]] = {
|
||||||
|
"tensorrt": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.tensorrt_runtime",
|
||||||
|
"TensorrtRuntime",
|
||||||
|
"BUILD_TENSORRT_RUNTIME",
|
||||||
|
),
|
||||||
|
"onnx_trt_ep": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.onnx_trt_ep_runtime",
|
||||||
|
"OnnxTrtEpRuntime",
|
||||||
|
"BUILD_ONNX_TRT_EP_RUNTIME",
|
||||||
|
),
|
||||||
|
"pytorch_fp16": (
|
||||||
|
"gps_denied_onboard.components.c7_inference.pytorch_fp16_runtime",
|
||||||
|
"PytorchFp16Runtime",
|
||||||
|
"BUILD_PYTORCH_FP16_RUNTIME",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Fakes that structurally satisfy the InferenceRuntime Protocol.
|
||||||
|
|
||||||
|
|
||||||
|
class _FullInferenceRuntime:
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._label = config.components["c7_inference"].runtime
|
||||||
|
|
||||||
|
def compile_engine(self, model_path, build_config):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def deserialize_engine(self, entry):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def infer(self, handle, inputs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def release_engine(self, handle):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def thermal_state(self):
|
||||||
|
return ThermalState(
|
||||||
|
cpu_temp_c=None,
|
||||||
|
gpu_temp_c=None,
|
||||||
|
thermal_throttle_active=False,
|
||||||
|
measured_clock_mhz=None,
|
||||||
|
measured_at_ns=0,
|
||||||
|
is_telemetry_available=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def current_runtime_label(self):
|
||||||
|
return self._label
|
||||||
|
|
||||||
|
|
||||||
|
class _PartialInferenceRuntime:
|
||||||
|
def compile_engine(self, model_path, build_config):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def deserialize_engine(self, entry):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def infer(self, handle, inputs):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def release_engine(self, handle):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def thermal_state(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def _config_with_runtime(runtime: str) -> Config:
|
||||||
|
return Config.with_blocks(
|
||||||
|
c7_inference=C7InferenceConfig(runtime=runtime)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_strategy(runtime_label: str) -> type:
|
||||||
|
module_name, class_name, _flag = _STRATEGY_MODULES[runtime_label]
|
||||||
|
|
||||||
|
class _FakeStrategy(_FullInferenceRuntime):
|
||||||
|
pass
|
||||||
|
|
||||||
|
_FakeStrategy.__name__ = class_name
|
||||||
|
module = types.ModuleType(module_name)
|
||||||
|
setattr(module, class_name, _FakeStrategy)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
return _FakeStrategy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def strategy_module_cleanup():
|
||||||
|
"""Pop every fake strategy module before/after each factory test."""
|
||||||
|
for module_name, _, _ in _STRATEGY_MODULES.values():
|
||||||
|
sys.modules.pop(module_name, None)
|
||||||
|
yield
|
||||||
|
for module_name, _, _ in _STRATEGY_MODULES.values():
|
||||||
|
sys.modules.pop(module_name, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-1: Protocol is conformance-checkable.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_inference_runtime_conformance_full() -> None:
|
||||||
|
instance = _FullInferenceRuntime(_config_with_runtime("pytorch_fp16"))
|
||||||
|
assert isinstance(instance, InferenceRuntime)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac1_inference_runtime_conformance_partial_missing_label() -> None:
|
||||||
|
assert not isinstance(_PartialInferenceRuntime(), InferenceRuntime)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-2: frozen DTOs reject mutation.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dto, field_name, new_value",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
BuildConfig(
|
||||||
|
precision=PrecisionMode.FP16,
|
||||||
|
workspace_mb=512,
|
||||||
|
calibration_dataset=None,
|
||||||
|
optimization_profiles=(),
|
||||||
|
),
|
||||||
|
"precision",
|
||||||
|
PrecisionMode.INT8,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
EngineCacheEntry(
|
||||||
|
engine_path=Path("/var/lib/x.engine"),
|
||||||
|
sha256_hex="a" * 64,
|
||||||
|
sm=87,
|
||||||
|
jp="6.2",
|
||||||
|
trt="10.3",
|
||||||
|
precision=PrecisionMode.FP16,
|
||||||
|
extras={},
|
||||||
|
),
|
||||||
|
"sha256_hex",
|
||||||
|
"b" * 64,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ThermalState(
|
||||||
|
cpu_temp_c=40.0,
|
||||||
|
gpu_temp_c=45.0,
|
||||||
|
thermal_throttle_active=False,
|
||||||
|
measured_clock_mhz=918,
|
||||||
|
measured_at_ns=1_000_000,
|
||||||
|
is_telemetry_available=True,
|
||||||
|
),
|
||||||
|
"thermal_throttle_active",
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
OptimizationProfile(
|
||||||
|
input_name="input",
|
||||||
|
min_shape=(1, 3, 224, 224),
|
||||||
|
opt_shape=(1, 3, 384, 384),
|
||||||
|
max_shape=(1, 3, 512, 512),
|
||||||
|
),
|
||||||
|
"input_name",
|
||||||
|
"renamed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac2_frozen_dtos_reject_mutation(dto, field_name: str, new_value) -> None:
|
||||||
|
original_value = getattr(dto, field_name)
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
setattr(dto, field_name, new_value)
|
||||||
|
assert getattr(dto, field_name) == original_value
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-3: error hierarchy catchable as a single family.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exc_factory",
|
||||||
|
[
|
||||||
|
EngineBuildError,
|
||||||
|
EngineDeserializeError,
|
||||||
|
EngineHashMismatchError,
|
||||||
|
EngineSchemaMismatchError,
|
||||||
|
EngineSidecarMissingError,
|
||||||
|
CalibrationCacheError,
|
||||||
|
InferenceError,
|
||||||
|
OutOfMemoryError,
|
||||||
|
TelemetryUnavailableError,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ac3_all_runtime_errors_caught_as_family(exc_factory) -> None:
|
||||||
|
with pytest.raises(C7RuntimeError):
|
||||||
|
raise exc_factory("boom")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_unrelated_exception_not_caught_as_family() -> None:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
try:
|
||||||
|
raise ValueError("not us")
|
||||||
|
except C7RuntimeError:
|
||||||
|
pytest.fail("ValueError must not be caught as c7 RuntimeError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac3_runtime_not_available_outside_family() -> None:
|
||||||
|
with pytest.raises(RuntimeNotAvailableError):
|
||||||
|
try:
|
||||||
|
raise RuntimeNotAvailableError("composition-time")
|
||||||
|
except C7RuntimeError:
|
||||||
|
pytest.fail(
|
||||||
|
"RuntimeNotAvailableError is a composition-root error and "
|
||||||
|
"MUST NOT be in the c7 runtime family"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-4 + AC-5: factory honours config + BUILD flag gate.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("runtime", sorted(_STRATEGY_MODULES))
|
||||||
|
def test_ac4_build_inference_runtime_returns_protocol_impl(
|
||||||
|
monkeypatch, strategy_module_cleanup, runtime
|
||||||
|
) -> None:
|
||||||
|
_, _, flag = _STRATEGY_MODULES[runtime]
|
||||||
|
monkeypatch.setenv(flag, "ON")
|
||||||
|
fake_cls = _install_fake_strategy(runtime)
|
||||||
|
config = _config_with_runtime(runtime)
|
||||||
|
instance = build_inference_runtime(config)
|
||||||
|
assert isinstance(instance, fake_cls)
|
||||||
|
assert isinstance(instance, InferenceRuntime)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("runtime", sorted(_STRATEGY_MODULES))
|
||||||
|
def test_ac5_build_inference_runtime_flag_off_no_import(
|
||||||
|
monkeypatch, strategy_module_cleanup, runtime
|
||||||
|
) -> None:
|
||||||
|
module_name, _, flag = _STRATEGY_MODULES[runtime]
|
||||||
|
monkeypatch.delenv(flag, raising=False)
|
||||||
|
config = _config_with_runtime(runtime)
|
||||||
|
with pytest.raises(RuntimeNotAvailableError) as exc_info:
|
||||||
|
build_inference_runtime(config)
|
||||||
|
assert runtime in str(exc_info.value)
|
||||||
|
assert flag in str(exc_info.value)
|
||||||
|
assert module_name not in sys.modules
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("runtime", sorted(_STRATEGY_MODULES))
|
||||||
|
def test_ac5_build_inference_runtime_flag_on_but_module_missing(
|
||||||
|
monkeypatch, strategy_module_cleanup, runtime
|
||||||
|
) -> None:
|
||||||
|
_, _, flag = _STRATEGY_MODULES[runtime]
|
||||||
|
monkeypatch.setenv(flag, "ON")
|
||||||
|
config = _config_with_runtime(runtime)
|
||||||
|
with pytest.raises(RuntimeNotAvailableError) as exc_info:
|
||||||
|
build_inference_runtime(config)
|
||||||
|
assert runtime in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-6: unknown runtime label rejected at config load.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad_label",
|
||||||
|
["tensorflow_lite", "onnx", "trt", "TENSORRT", ""],
|
||||||
|
)
|
||||||
|
def test_ac6_unknown_runtime_rejected_at_config_load(bad_label: str) -> None:
|
||||||
|
with pytest.raises(ConfigError) as exc_info:
|
||||||
|
C7InferenceConfig(runtime=bad_label)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
assert bad_label in msg or "runtime" in msg
|
||||||
|
for valid in KNOWN_RUNTIMES:
|
||||||
|
assert valid in msg
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-7: current_runtime_label() matches config exactly.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("runtime", sorted(_STRATEGY_MODULES))
|
||||||
|
def test_ac7_current_runtime_label_matches_config(
|
||||||
|
monkeypatch, strategy_module_cleanup, runtime
|
||||||
|
) -> None:
|
||||||
|
_, _, flag = _STRATEGY_MODULES[runtime]
|
||||||
|
monkeypatch.setenv(flag, "ON")
|
||||||
|
_install_fake_strategy(runtime)
|
||||||
|
config = _config_with_runtime(runtime)
|
||||||
|
instance = build_inference_runtime(config)
|
||||||
|
assert instance.current_runtime_label() == runtime
|
||||||
|
assert instance.current_runtime_label() == config.components["c7_inference"].runtime
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# AC-8: contract file matches Protocol shape.
|
||||||
|
|
||||||
|
|
||||||
|
_METHOD_TABLE_RE = re.compile(r"^\|\s*`(?P<name>[a-z_][a-z0-9_]*)`\s*\|", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _methods_from_contract() -> set[str]:
|
||||||
|
text = _CONTRACT_PATH.read_text(encoding="utf-8")
|
||||||
|
surface_start = text.index("### Protocol surface")
|
||||||
|
next_section = text.find("\n### ", surface_start + len("### Protocol surface"))
|
||||||
|
section = text[surface_start:next_section] if next_section != -1 else text[surface_start:]
|
||||||
|
return {m.group("name") for m in _METHOD_TABLE_RE.finditer(section)}
|
||||||
|
|
||||||
|
|
||||||
|
def _protocol_methods(proto: type) -> set[str]:
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in dir(proto)
|
||||||
|
if not name.startswith("_") and callable(getattr(proto, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac8_contract_methods_match_protocol() -> None:
|
||||||
|
contract_methods = _methods_from_contract()
|
||||||
|
protocol_methods = _protocol_methods(InferenceRuntime)
|
||||||
|
missing_in_protocol = contract_methods - protocol_methods
|
||||||
|
missing_in_contract = protocol_methods - contract_methods
|
||||||
|
assert not missing_in_protocol, (
|
||||||
|
"Methods declared in inference_runtime_protocol.md Shape section "
|
||||||
|
f"but missing from the Protocol: {sorted(missing_in_protocol)}"
|
||||||
|
)
|
||||||
|
assert not missing_in_contract, (
|
||||||
|
"Methods present on the Protocol but missing from the contract "
|
||||||
|
f"Shape section: {sorted(missing_in_contract)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ac8_contract_lists_all_nine_error_subtypes() -> None:
|
||||||
|
text = _CONTRACT_PATH.read_text(encoding="utf-8")
|
||||||
|
expected = {
|
||||||
|
"EngineBuildError",
|
||||||
|
"EngineDeserializeError",
|
||||||
|
"EngineHashMismatchError",
|
||||||
|
"EngineSchemaMismatchError",
|
||||||
|
"EngineSidecarMissingError",
|
||||||
|
"CalibrationCacheError",
|
||||||
|
"InferenceError",
|
||||||
|
"OutOfMemoryError",
|
||||||
|
"TelemetryUnavailableError",
|
||||||
|
}
|
||||||
|
for name in expected:
|
||||||
|
assert name in text, (
|
||||||
|
f"Contract file is missing the documented error subtype {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# NFRs.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exc_type",
|
||||||
|
[
|
||||||
|
EngineBuildError,
|
||||||
|
EngineDeserializeError,
|
||||||
|
EngineHashMismatchError,
|
||||||
|
EngineSchemaMismatchError,
|
||||||
|
EngineSidecarMissingError,
|
||||||
|
CalibrationCacheError,
|
||||||
|
InferenceError,
|
||||||
|
OutOfMemoryError,
|
||||||
|
TelemetryUnavailableError,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_nfr_reliability_all_runtime_errors_subclass_family(exc_type) -> None:
|
||||||
|
assert issubclass(exc_type, C7RuntimeError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nfr_reliability_runtime_not_available_not_in_family() -> None:
|
||||||
|
assert not issubclass(RuntimeNotAvailableError, C7RuntimeError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nfr_perf_factory_under_200ms_p99(
|
||||||
|
monkeypatch, strategy_module_cleanup
|
||||||
|
) -> None:
|
||||||
|
"""Factory p99 ≤ 200 ms across 1000 calls (NFR-perf-factory)."""
|
||||||
|
runtime = "pytorch_fp16"
|
||||||
|
_, _, flag = _STRATEGY_MODULES[runtime]
|
||||||
|
monkeypatch.setenv(flag, "ON")
|
||||||
|
_install_fake_strategy(runtime)
|
||||||
|
config = _config_with_runtime(runtime)
|
||||||
|
|
||||||
|
durations_ms: list[float] = []
|
||||||
|
for _ in range(1000):
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
build_inference_runtime(config)
|
||||||
|
durations_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||||
|
|
||||||
|
durations_ms.sort()
|
||||||
|
p99 = durations_ms[int(0.99 * len(durations_ms))]
|
||||||
|
assert p99 <= 200.0, (
|
||||||
|
f"build_inference_runtime() p99={p99:.3f} ms exceeds 200 ms NFR"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Surface coverage.
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_handle_is_class_not_protocol() -> None:
|
||||||
|
"""C7 EngineHandle is an opaque class — not a runtime_checkable Protocol.
|
||||||
|
|
||||||
|
Distinguishes it from the LightGlue ``_types.manifests.EngineHandle``
|
||||||
|
Protocol (intentional dual-name design; see manifests.py docstring).
|
||||||
|
"""
|
||||||
|
assert isinstance(EngineHandle, type)
|
||||||
|
assert not hasattr(EngineHandle, "_is_runtime_protocol")
|
||||||
|
|
||||||
|
|
||||||
|
def test_c7_config_thermal_poll_hz_validation() -> None:
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
C7InferenceConfig(thermal_poll_hz=0.0)
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
C7InferenceConfig(thermal_poll_hz=-1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c7_config_engine_cache_dir_validation() -> None:
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
C7InferenceConfig(engine_cache_dir="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_precision_mode_enum_surface() -> None:
|
||||||
|
assert {v.value for v in PrecisionMode} == {"fp16", "int8", "mixed"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_thermal_state_invariant_i6_default_safe() -> None:
|
||||||
|
"""When telemetry is unavailable, throttle MUST be False (Invariant I-6)."""
|
||||||
|
ts = ThermalState(
|
||||||
|
cpu_temp_c=None,
|
||||||
|
gpu_temp_c=None,
|
||||||
|
thermal_throttle_active=False,
|
||||||
|
measured_clock_mhz=None,
|
||||||
|
measured_at_ns=0,
|
||||||
|
is_telemetry_available=False,
|
||||||
|
)
|
||||||
|
assert ts.thermal_throttle_active is False
|
||||||
|
assert ts.is_telemetry_available is False
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
"""C7 InferenceRuntime smoke test — AC-9."""
|
|
||||||
|
|
||||||
|
|
||||||
def test_interface_importable() -> None:
|
|
||||||
# Assert
|
|
||||||
from gps_denied_onboard.components.c7_inference import (
|
|
||||||
EngineCacheEntry,
|
|
||||||
InferenceRuntime,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert InferenceRuntime is not None
|
|
||||||
assert EngineCacheEntry is not None
|
|
||||||
@@ -93,6 +93,36 @@ def _kind_payload(kind: str) -> dict[str, object]:
|
|||||||
"rollover_count": 0,
|
"rollover_count": 0,
|
||||||
"clean_shutdown": True,
|
"clean_shutdown": True,
|
||||||
}
|
}
|
||||||
|
# AZ-490 / E-C5: operator-origin cold-start ladder + bounded-delta GPS gate.
|
||||||
|
if kind == "c5.cold_start_origin.set":
|
||||||
|
return {
|
||||||
|
"source": "manifest",
|
||||||
|
"lat_deg": 50.0,
|
||||||
|
"lon_deg": 36.2,
|
||||||
|
"alt_m": 200.0,
|
||||||
|
"sigma_horiz_m": 5.0,
|
||||||
|
"sigma_vert_m": 10.0,
|
||||||
|
}
|
||||||
|
if kind == "c5.cold_start_origin.unavailable":
|
||||||
|
return {"reason": "no_manifest_origin_no_fc_ekf"}
|
||||||
|
if kind == "c5.gps_bounded_delta.accept":
|
||||||
|
return {
|
||||||
|
"sample_lat": 50.0,
|
||||||
|
"sample_lon": 36.2,
|
||||||
|
"smoother_lat": 50.0001,
|
||||||
|
"smoother_lon": 36.2,
|
||||||
|
"distance_m": 11.1,
|
||||||
|
"threshold_m": 200.0,
|
||||||
|
}
|
||||||
|
if kind == "c5.gps_bounded_delta.reject":
|
||||||
|
return {
|
||||||
|
"sample_lat": 50.0,
|
||||||
|
"sample_lon": 36.2,
|
||||||
|
"smoother_lat": 50.005,
|
||||||
|
"smoother_lon": 36.205,
|
||||||
|
"distance_m": 700.0,
|
||||||
|
"threshold_m": 200.0,
|
||||||
|
}
|
||||||
raise AssertionError(f"unhandled kind in fixture: {kind!r}")
|
raise AssertionError(f"unhandled kind in fixture: {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user