mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:41:13 +00:00
[AZ-390] [AZ-392] C8 FC/GCS adapter foundation + covariance projector
Adds the C8 foundation: - FcAdapter / GcsAdapter / ReplaySink Protocols + contract DTOs in _types/fc.py (PortConfig, FcKind, FlightState, GpsStatus, Severity, TelemetryKind, FcTelemetryFrame, FlightStateSignal, GpsHealth, OperatorCommand, Subscription, Imu/Attitude samples). - Disjoint FcAdapterError / GcsAdapterError trees with SourceSetSwitchNotSupportedError <: SourceSetSwitchError per AC-9. - FcConfig + GcsConfig cross-cutting Config blocks with config-load validation (unknown strategy rejected at __post_init__). - runtime_root/fc_factory.py: build_fc_adapter / build_gcs_adapter with BUILD_FC_*/BUILD_GCS_* flag gating + INFO log on load + single-writer outbound-thread binding. - CovarianceProjector (helper, AZ-392): 6x6 -> 3x3 -> 2x2 -> sqrt(lambda_max) reduction; AP returns float m, iNav returns int mm with uint16 clamp + WARN + FDR record. Non-SPD / NaN / wrong-shape raise FcEmitError and emit an FDR ERROR record carrying frame_id. Contracts: - composition_root_protocol.md 1.1.0 -> 1.2.0 (added fc/gcs blocks + build_fc_adapter / build_gcs_adapter + outbound-thread binding). - fc_adapter_protocol.md unchanged (this batch implements v1.0.0). Tests: 410 pass / 2 skip / 0 fail (+53 new tests in batch 8). AZ-391 (inbound subscription) deferred to batch 9 — pulls YAMSPy as a new external dependency (iNav MSP2 decode). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,103 +0,0 @@
|
||||
# C8 FcAdapter / GcsAdapter Protocols + DTOs + Factories + Composition
|
||||
|
||||
**Task**: AZ-390_c8_adapter_protocol
|
||||
**Name**: C8 `FcAdapter` + `GcsAdapter` Protocols + DTOs + errors + composition factories
|
||||
**Description**: Define the public `FcAdapter` and `GcsAdapter` Protocols (PEP 544 `@runtime_checkable`), the C8 DTOs (`PortConfig`, `FcKind` enum, `FcTelemetryFrame`, `TelemetryKind` enum + payload union, `FlightStateSignal`, `FlightState` enum, `GpsHealth`, `GpsStatus` enum, `Severity` enum, `EmittedExternalPosition`, `OperatorCommand`), the error hierarchy (`FcAdapterError` family + `GcsAdapterError` family per the contract), and the composition-root factories `build_fc_adapter(...) -> FcAdapter` + `build_gcs_adapter(...) -> GcsAdapter` with strategy resolution (`config.fc.adapter`, `config.gcs.adapter`) and `BUILD_FC_<variant>` / `BUILD_GCS_<variant>` flag gating per ADR-002. Composition root binds C8 outbound (`emit_external_position`, `emit_status_text`, `request_source_set_switch`) to a single emit thread; C8 inbound (`subscribe_telemetry`) fires on the inbound decode thread. Shared helpers (`WgsConverter` AZ-279, `SE3Utils` AZ-277, `FdrClient` AZ-273, `Clock`) constructor-injected. Config schema extension for `fc.{adapter, port_device, port_baud, signing_key_source}` and `gcs.{adapter, port_device, port_baud, summary_rate_hz}`. No wire encoding, no signing logic, no telemetry decoding in scope here — pure scaffolding the seven downstream consumer tasks depend on.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-263, AZ-269, AZ-270, AZ-273 (`FdrClient`), AZ-277 (`SE3Utils`), AZ-279 (`WgsConverter`), AZ-266
|
||||
**Component**: c8_fc_adapter (epic AZ-261 / E-C8)
|
||||
**Tracker**: AZ-390
|
||||
**Epic**: AZ-261 (E-C8)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — the public contract this task implements.
|
||||
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 1 overview, § 2 interfaces, § 5 implementation details.
|
||||
- `_docs/02_document/architecture.md` — ADR-001, ADR-002, ADR-009.
|
||||
- `_docs/02_document/module-layout.md` — `c8_fc_adapter` Per-Component Mapping.
|
||||
|
||||
## Problem
|
||||
|
||||
Without this task, no concrete C8 adapter has a Protocol to register against; the runtime root cannot wire C8 to C1 / C5 (which receive `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` exclusively via the constructor-injected `FcAdapter` interface); the seven downstream consumer tasks (inbound subscription, covariance projector, AP outbound, iNav outbound, signing handshake, source-set switch, GCS adapter) have no shared DTO surface to encode/decode against.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/interface.py` — `FcAdapter`, `GcsAdapter` Protocols with all methods per the contract.
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/__init__.py` — re-exports `FcAdapter`, `GcsAdapter`, `EmittedExternalPosition`.
|
||||
- `src/gps_denied_onboard/_types/fc.py` — `PortConfig`, `FcKind`, `FcTelemetryFrame`, `TelemetryKind`, `FlightStateSignal`, `FlightState`, `GpsHealth`, `GpsStatus`, `Severity`, `EmittedExternalPosition`, `OperatorCommand` (all frozen + slots).
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/errors.py` — full error hierarchy.
|
||||
- `src/gps_denied_onboard/runtime_root/fc_factory.py` — `build_fc_adapter(...)` + `build_gcs_adapter(...)`. Lazy-import per ADR-002.
|
||||
- Composition-root extension: invoke `build_fc_adapter` AFTER C5; invoke `build_gcs_adapter` AFTER `build_fc_adapter`; bind outbound to ONE emit thread (single-writer invariant).
|
||||
- Config schema extension for `fc.*` + `gcs.*` fields.
|
||||
- INFO log on successful build: `kind="c8.adapter.strategy_loaded"` with `{fc_kind, gcs_kind}`.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Both Protocols with all methods.
|
||||
- All DTOs + enums.
|
||||
- Error hierarchy.
|
||||
- Both factories + composition-root wiring.
|
||||
- Single-writer thread enforcement for outbound.
|
||||
- Config schema extension.
|
||||
- Unit tests: Protocol conformance, DTO immutability + slots, factory rejection on unknown strategy + missing build flag, single-thread enforcement.
|
||||
|
||||
### Excluded
|
||||
- Inbound MAVLink + MSP2 decoder bodies — owned by next task.
|
||||
- `CovarianceProjector` — owned by next task.
|
||||
- `PymavlinkArdupilotAdapter` outbound body — owned by AP outbound task.
|
||||
- `Msp2InavAdapter` outbound body — owned by iNav outbound task.
|
||||
- MAVLink 2.0 signing handshake — owned by signing task.
|
||||
- D-C8-2 source-set switch body — owned by source-set task.
|
||||
- `QgcTelemetryAdapter` body — owned by GCS task.
|
||||
- C8-IT/PT/ST tests — deferred to E-BBT (AZ-262).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Protocol conformance** — `runtime_checkable` `isinstance` returns True for fakes implementing each Protocol's full method set.
|
||||
|
||||
**AC-2: DTOs frozen + slots** — `FrozenInstanceError` on mutation; `__slots__` non-empty for every DTO.
|
||||
|
||||
**AC-3: Enum membership** — `FcKind` has 2 values (ARDUPILOT_PLANE, INAV); `FlightState` has 5 (INIT/ARMED/IN_FLIGHT/ON_GROUND/FAILED); `GpsStatus` has 5 (NO_FIX/DEGRADED/STABLE/STABLE_NON_SPOOFED/SPOOFED); `Severity` has 3 (INFO=6, WARNING=4, ERROR=3 — values mirror MAVLink STATUSTEXT severities).
|
||||
|
||||
**AC-4: Factory rejects missing build flag** — `config.fc.adapter = "ardupilot_plane"` with `BUILD_FC_ARDUPILOT_PLANE=OFF` → `FcAdapterConfigError("BUILD_FC_ARDUPILOT_PLANE is OFF...")`.
|
||||
|
||||
**AC-5: Factory rejects unknown strategy at config-load** — `config.fc.adapter = "garbage"` → `FcAdapterConfigError` at config load (NOT at build time).
|
||||
|
||||
**AC-6: Single-writer thread for outbound** — composition root binds outbound to ONE thread; second binding raises `RuntimeError`.
|
||||
|
||||
**AC-7: GCS factory parallel coverage** — same set of acceptance behaviours for `build_gcs_adapter` against the `GcsAdapter` Protocol.
|
||||
|
||||
**AC-8: Public API re-exports** — `from gps_denied_onboard.components.c8_fc_adapter import FcAdapter, GcsAdapter, EmittedExternalPosition` resolves; internal modules NOT in `__all__`.
|
||||
|
||||
**AC-9: Error hierarchy catchability** — every FC error caught by `except FcAdapterError`; every GCS error caught by `except GcsAdapterError`. `SourceSetSwitchNotSupportedError` is also a `SourceSetSwitchError` (sub-typed for iNav rejection).
|
||||
|
||||
**AC-10: INFO log on build** — successful build logs `kind="c8.adapter.strategy_loaded"` once per adapter with the strategy name + port device.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- `build_fc_adapter` p99 ≤ 50 ms.
|
||||
- `build_gcs_adapter` p99 ≤ 50 ms.
|
||||
|
||||
## Constraints
|
||||
|
||||
- `@runtime_checkable` on both Protocols; DTOs `frozen=True, slots=True`.
|
||||
- Lazy-import per ADR-002.
|
||||
- Single-thread binding enforced for outbound (AC-6).
|
||||
- Public API surface limited to the two re-export sets (per `module-layout.md`).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk**: Protocol surface changes after consumer tasks land. *Mitigation*: this task ships first; downstream tasks reference the Protocol shape locked here. Any extension is additive (new method on the Protocol implies a default no-op fallback or a follow-up Protocol version bump documented in the contract).
|
||||
- **Risk**: Single-thread binding bug breaks the multi-consumer (C1 + C5) inbound path. *Mitigation*: AC-6 covers ONLY outbound; inbound subscribe-callback semantics are documented as fire-on-decode-thread + consumer responsibility (Invariant 8).
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: C8 Protocols + DTOs + factories.
|
||||
- **Production code**: real Protocols, real DTOs, real error hierarchy, real factories, real composition-root wiring.
|
||||
- **Allowed external stubs**: test fakes only; no production code may import `FcAdapterStub` outside tests.
|
||||
- **Unacceptable substitutes**: hardcoding the C8 strategy class in the runtime root (defeats ADR-009); skipping the Protocol surface.
|
||||
|
||||
## Contract
|
||||
|
||||
Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md`.
|
||||
@@ -1,87 +0,0 @@
|
||||
# C8 CovarianceProjector — honest 6×6 → 2×2 → equivalent_radius
|
||||
|
||||
**Task**: AZ-392_c8_covariance_projector
|
||||
**Name**: C8 `CovarianceProjector` — honest 6×6 → 2×2 → equivalent_radius helper (D-C8-8 = (b))
|
||||
**Description**: Implement the `CovarianceProjector` class — a C8-internal helper that projects a 6×6 GTSAM `Marginals` covariance from `EstimatorOutput.covariance_6x6` into the per-FC scalar accuracy field. Steps: 6×6 → 3×3 position sub-matrix (top-left 3×3 block) → 2×2 horizontal sub-matrix (rows/cols 0,1) → `equivalent_radius` per the AC-4.3 formula `sqrt(0.5 * (sigma_xx + sigma_yy + sqrt((sigma_xx - sigma_yy)^2 + 4*sigma_xy^2)))` (largest eigenvalue of 2×2). Two output paths: (i) `to_ardupilot_horiz_accuracy_m(cov_6x6) -> float` returning meters for AP `GPS_INPUT.horiz_accuracy`; (ii) `to_inav_h_pos_accuracy_mm(cov_6x6) -> int` returning millimeters (clamped to int range) for iNav `MSP2_SENSOR_GPS.hPosAccuracy`. Documented as the "honest covariance projection" per IT-10. Constructor injection (no static methods — per coderule SRP, this helper has variant-specific output formatting that belongs on an instance). NaN / non-SPD input → `FcEmitError("non-SPD covariance from C5; refusing emit")`.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-390 (error hierarchy includes `FcEmitError`), AZ-263, AZ-269, AZ-266, AZ-272 (FDR for the SPD-violation log)
|
||||
**Component**: c8_fc_adapter (epic AZ-261 / E-C8)
|
||||
**Tracker**: AZ-392
|
||||
**Epic**: AZ-261 (E-C8)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariant 4 (honest projection); error path for non-SPD.
|
||||
- `_docs/02_document/components/10_c8_fc_adapter/description.md` — § 5 covariance projection formula; § 6 helper ownership table.
|
||||
|
||||
## Problem
|
||||
|
||||
Without this task, both AP and iNav outbound paths must each implement covariance projection independently — risking drift between adapters AND violating Invariant 4 (honest projection). The Frobenius-norm equivalence requirement (within 1% per C8-IT-01) demands a single canonical implementation.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/_covariance_projector.py` — `CovarianceProjector` class with two methods.
|
||||
- Re-exported as `c8_fc_adapter.helpers.CovarianceProjector` for internal C8 use only (NOT in Public API per `module-layout.md`).
|
||||
- Constructor: `CovarianceProjector(fdr_client: FdrClient)` — for SPD-violation logging.
|
||||
- Unit tests: known-input vs hand-computed expected output; Frobenius-norm equivalence within 1% on 100 synthetic 6×6 SPD matrices; non-SPD raises; NaN raises; clipping at iNav int max documented.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- `CovarianceProjector` class with both per-FC methods.
|
||||
- 6×6 → 3×3 → 2×2 → eigenvalue formula.
|
||||
- AP meters output + iNav millimeters output.
|
||||
- SPD validation + NaN check.
|
||||
- Unit tests including Frobenius-norm equivalence (the C8-IT-01 acceptance test exercises this from end-to-end; this task's unit tests cover the projector body).
|
||||
|
||||
### Excluded
|
||||
- AP / iNav wire encoding — owned by outbound tasks (they consume this projector).
|
||||
- The C8-IT-01 end-to-end test — deferred to E-BBT.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Hand-computed reference** — for `cov_6x6` with known position-block `[[4, 1, 0], [1, 9, 0], [0, 0, 16]]`, `to_ardupilot_horiz_accuracy_m(...)` returns `sqrt(0.5 * (4 + 9 + sqrt((4-9)^2 + 4)))` ≈ `sqrt(6.5 + sqrt(29)/2)` (assert within 1e-9 numerical tolerance).
|
||||
|
||||
**AC-2: Frobenius-norm equivalence** — on 100 synthetic 6×6 SPD matrices, the 2×2 horizontal-block Frobenius norm is within 1% of the 3×3 position-block horizontal-component Frobenius norm. (This is a slightly weaker form of C8-IT-01 — exact equivalence is exercised end-to-end.)
|
||||
|
||||
**AC-3: AP units = meters** — `to_ardupilot_horiz_accuracy_m` returns a `float`; documented as meters.
|
||||
|
||||
**AC-4: iNav units = millimeters** — `to_inav_h_pos_accuracy_mm` returns an `int`; documented as millimeters; conversion is `m * 1000.0` rounded half-up to int.
|
||||
|
||||
**AC-5: iNav int clamping** — covariance with `equivalent_radius > 65.535 m` (uint16 max in mm) → returned value clamped to 65535; WARN log emitted with `kind="c8.cov_projector.inav_clamped"` on every clamp event.
|
||||
|
||||
**AC-6: Non-SPD raises** — input with negative eigenvalue (e.g., `[[1, 0, 0], [0, -1, 0], [0, 0, 1]]` in the position block) → `FcEmitError("non-SPD covariance from C5; refusing emit")`.
|
||||
|
||||
**AC-7: NaN raises** — input with any NaN entry → `FcEmitError("NaN covariance from C5; refusing emit")`.
|
||||
|
||||
**AC-8: SPD-violation FDR log** — every SPD-violation (AC-6) and NaN (AC-7) emits an FDR record `kind="c8.cov_projector.spd_violation"` BEFORE raising.
|
||||
|
||||
**AC-9: Per-FC same source** — `to_ardupilot_horiz_accuracy_m(cov) * 1000` equals `to_inav_h_pos_accuracy_mm(cov)` ± 1 (rounding), for any well-conditioned input.
|
||||
|
||||
**AC-10: No global state** — two independent `CovarianceProjector` instances do not share state (instance method; not static — per SRP).
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- p99 ≤ 100 µs per call (constant-time per § 5).
|
||||
|
||||
## Constraints
|
||||
|
||||
- Instance method (NOT static) per coderule SRP — variant-specific output formatting on the instance.
|
||||
- Public API: NOT exposed outside C8 (helper-only).
|
||||
- numpy is the only allowed dependency for the eigenvalue computation.
|
||||
- The two methods MUST share the same intermediate 3×3 / 2×2 reduction code path — variant-specific code is the unit conversion at the end (per coderule SRP).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk: numpy eigvalsh vs custom 2×2 closed-form gives different results within numerical tolerance** — *Mitigation*: use the closed-form `sqrt(0.5 * (a + d + sqrt((a-d)^2 + 4*b^2)))` directly; faster + bit-stable.
|
||||
- **Risk: SPD-violation cascades into a tight error loop in production** — *Mitigation*: the projector raises; the AP / iNav outbound task is responsible for handling `FcEmitError` + dropping the frame + continuing. C5's emit thread does not retry on SPD violation.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: honest 6×6 → 2×2 → equivalent_radius projection for both FC variants.
|
||||
- **Production code**: real numpy-based reduction; real per-FC unit conversion; real SPD + NaN guards; real FDR logging.
|
||||
- **Unacceptable substitutes**: a constant fixed-radius output ("3.0 m always") — defeats AC-4.3 and Invariant 4.
|
||||
|
||||
## Contract
|
||||
|
||||
Implements `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — Invariant 4.
|
||||
Reference in New Issue
Block a user