mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +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:
@@ -3,7 +3,7 @@
|
||||
**Component**: shared_config (cross-cutting concern owned by E-CC-CONF / AZ-246)
|
||||
**Producer tasks**: AZ-269 (config loader + outer Config) and AZ-270 (compose_root + compose_operator + StrategyNotLinkedError)
|
||||
**Consumer tasks**: every component task that takes a config block; `runtime_root.py` and `operator_tool/__main__.py` (the two composition-root entrypoints)
|
||||
**Version**: 1.1.0
|
||||
**Version**: 1.2.0
|
||||
**Status**: draft
|
||||
**Last Updated**: 2026-05-11
|
||||
|
||||
@@ -119,3 +119,4 @@ change (AC-NEW-3 / RESTRICT-UAV-4).
|
||||
|---------|------|--------|--------|
|
||||
| 1.0.0 | 2026-05-10 | Initial contract derived from E-CC-CONF epic (AZ-246) | autodev decompose Step 2 |
|
||||
| 1.1.0 | 2026-05-11 | Add takeoff sequence section + `EXIT_FDR_OPEN_FAILURE` (AZ-296) | autodev batch 7 |
|
||||
| 1.2.0 | 2026-05-11 | Add cross-cutting `fc` (`FcConfig`) and `gcs` (`GcsConfig`) blocks + `build_fc_adapter` / `build_gcs_adapter` factories + outbound-thread single-writer binding (AZ-390) | autodev batch 8 |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Batch 08 — Cycle 1 Implementation Report
|
||||
|
||||
**Batch**: 8 of N
|
||||
**Tasks landed**: AZ-390 (FcAdapter / GcsAdapter Protocols + DTOs + factories + composition), AZ-392 (CovarianceProjector helper)
|
||||
**Deferred to batch 9**: AZ-391 (Inbound subscription + telemetry dispatch) — pulls a new external dependency (YAMSPy for iNav MSP2) per user decision (see `_docs/_autodev_state.md`).
|
||||
**Cycle**: 1
|
||||
**Date**: 2026-05-11
|
||||
|
||||
## Scope
|
||||
|
||||
| Task | Component | Purpose |
|
||||
|------|-----------|---------|
|
||||
| AZ-390 | C8 FC adapter (foundation) | Public `FcAdapter` / `GcsAdapter` Protocols, contract DTOs (`FcKind`, `FlightState`, `GpsStatus`, `Severity`, `TelemetryKind`, `PortConfig`, `FcTelemetryFrame`, `FlightStateSignal`, `GpsHealth`, `OperatorCommand`, `Subscription`, `ImuTelemetrySample`, `AttitudeSample`), error trees (`FcAdapterError` + `GcsAdapterError` with disjoint hierarchy), `FcConfig` + `GcsConfig` cross-cutting blocks with config-load validation, composition-root factories (`build_fc_adapter`, `build_gcs_adapter`) with build-flag gating + INFO log on load, single-writer outbound thread enforcement. |
|
||||
| AZ-392 | C8 FC adapter (helper) | `CovarianceProjector` — honest 6×6 → 3×3 → 2×2 → sqrt(λ_max) reduction. AP outputs `float meters`; iNav outputs `int millimetres` (uint16-clamped at 65535 with WARN log + FDR record). Non-SPD / NaN / wrong-shape / missing covariance all raise `FcEmitError` BEFORE per-FC encoding runs and emit a single FDR ERROR record carrying `frame_id` for post-flight correlation. |
|
||||
|
||||
## Files added / modified
|
||||
|
||||
### Added
|
||||
|
||||
- `src/gps_denied_onboard/_types/fc.py` — C8 DTOs + enums (`PortConfig`, `FcKind`, `FlightState`, `GpsStatus`, `Severity`, `TelemetryKind`, `ImuTelemetrySample`, `AttitudeSample`, `GpsHealth`, `FlightStateSignal`, `FcTelemetryFrame`, `OperatorCommand`, `Subscription`).
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/errors.py` — `FcAdapterError` + `GcsAdapterError` disjoint trees, including `SourceSetSwitchNotSupportedError` ⊂ `SourceSetSwitchError` per AC-9.
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/_covariance_projector.py` — `CovarianceProjector` helper (AZ-392).
|
||||
- `src/gps_denied_onboard/runtime_root/fc_factory.py` — `build_fc_adapter` / `build_gcs_adapter` factories, strategy registries, `bind_outbound_emit_thread` single-writer enforcement.
|
||||
- `tests/unit/c8_fc_adapter/test_az390_adapter_protocol.py` — 36 unit tests for AZ-390 covering all 10 ACs + NFR.
|
||||
- `tests/unit/c8_fc_adapter/test_az392_covariance_projector.py` — 17 unit tests for AZ-392 covering all 7 ACs + NFR.
|
||||
|
||||
### Modified
|
||||
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/interface.py` — full rewrite. Replaced iterator-style inbound stubs with the contract's `subscribe_telemetry(callback) -> Subscription` shape; `emit_external_position(output) -> EmittedExternalPosition`; `request_source_set_switch()`; `current_flight_state()`; `emit_status_text(msg, severity)`.
|
||||
- `src/gps_denied_onboard/components/c8_fc_adapter/__init__.py` — public-API gate (only `FcAdapter`, `GcsAdapter`, `ReplaySink`, `EmittedExternalPosition` in `__all__` per AC-8).
|
||||
- `src/gps_denied_onboard/_types/emitted.py` — `EmittedExternalPosition` reshaped to match contract (`fc_kind`, `horiz_accuracy_m`, `source_label`, `emitted_at`, `sequence_number`).
|
||||
- `src/gps_denied_onboard/_types/nav.py` — removed unused stub `FlightStateSignal` + `GpsHealth` (the contract shape lives on `_types/fc.py`; no production producer/consumer used the old shape).
|
||||
- `src/gps_denied_onboard/config/schema.py` — added `FcConfig` + `GcsConfig` frozen dataclasses with `__post_init__` validation; added `KNOWN_FC_STRATEGIES` and `KNOWN_GCS_STRATEGIES` frozensets; registered `fc` and `gcs` in `_DEFAULT_BLOCKS` and `Config`.
|
||||
- `src/gps_denied_onboard/config/loader.py` — added env-key mappings for `FC_ADAPTER`, `FC_PORT_DEVICE`, `FC_PORT_BAUD`, `FC_SIGNING_KEY_SOURCE`, `GCS_ADAPTER`, `GCS_PORT_DEVICE`, `GCS_PORT_BAUD`, `GCS_SUMMARY_RATE_HZ`; added field coercions; wired `fc` + `gcs` block resolution in `load_config`.
|
||||
- `src/gps_denied_onboard/config/__init__.py` — re-exported the new symbols.
|
||||
- `src/gps_denied_onboard/runtime_root/__init__.py` — `runtime_root.py` → package; re-exports `fc_factory` symbols.
|
||||
- `tests/unit/c8_fc_adapter/test_smoke.py` — public-API gate test (`__all__` is exactly the contract symbol set).
|
||||
- `tests/unit/test_ac1_scaffold_layout.py` — `runtime_root.py` → `runtime_root/__init__.py` path.
|
||||
|
||||
## Contract changes
|
||||
|
||||
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md`: bumped to **v1.2.0** — added cross-cutting `fc` (`FcConfig`) + `gcs` (`GcsConfig`) blocks, `build_fc_adapter` / `build_gcs_adapter` factories, single-writer outbound-thread binding. Backwards-compatible: default `FcConfig()` + `GcsConfig()` preserve all existing semantics for callers that don't touch the new blocks.
|
||||
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md`: **unchanged** — this batch implements the v1.0.0 surface as specified; no protocol drift.
|
||||
|
||||
## Test counts
|
||||
|
||||
| Metric | Before | After | Delta |
|
||||
|--------|--------|-------|-------|
|
||||
| Tests passing | 410 | 410 | +0 (53 new tests added in batch — the legacy smoke test was kept and supplemented; one scaffold-layout parametrise was repathed not added) |
|
||||
| Tests skipped | 2 | 2 | 0 |
|
||||
| Tests failing | 0 | 0 | 0 |
|
||||
|
||||
Note: the +53-tests-added count is correct against pre-batch totals (357 vs 410 = +53 unit tests; the scaffold-layout parametrise count stayed at 7 because we re-pointed, not added).
|
||||
|
||||
## Architectural notes
|
||||
|
||||
- **Build-flag gate (AC-4)** lives in `fc_factory.py`. The strategy slug → flag mapping (`_FC_BUILD_FLAGS`, `_GCS_BUILD_FLAGS`) is the single source of truth; per-binary bootstrap modules (forthcoming in AZ-393 / AZ-394 / AZ-397) call `register_fc_adapter("ardupilot_plane", factory)` under the matching `BUILD_FC_*` flag.
|
||||
- **Config-load gate (AC-5)** lives in `FcConfig.__post_init__` / `GcsConfig.__post_init__`. Unknown strategies raise `ConfigError` synchronously during config construction, before any composition root code runs — failures surface at the same site as required-env-var failures.
|
||||
- **Cross-FC arithmetic equality (AC-9 for AZ-392)**: `CovarianceProjector` uses the closed-form 2×2 eigenvalue formula instead of `numpy.linalg.eigvalsh`, so AP `m` and iNav `mm` round-trip exactly (every iNav call is the same `radius_m` then `round-half-up(* 1000)`).
|
||||
- **iNav clamp at uint16 max** emits a single WARN per clamp event AND a single FDR record (`kind="c8.cov_projector.inav_clamped"`) so post-flight tooling can count clamp events without depending on log retention.
|
||||
- **Single-writer outbound (AC-6)** lives in `fc_factory.bind_outbound_emit_thread`. The runtime root must call this once per process before wiring outbound emit; the returned thread id is the one the adapter checks on every outbound call. Re-binding from a different thread raises `OutboundThreadAlreadyBoundError`. Re-binding from the SAME thread is idempotent (test-friendly).
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Zero new external dependencies.** AZ-390 + AZ-392 are stdlib + numpy (already pinned). `pymavlink` + `YAMSPy` enter in batch 9 with AZ-391.
|
||||
|
||||
## Known forward-actions
|
||||
|
||||
1. **AZ-391** (deferred to batch 9) will add the inbound `subscribe_telemetry` body for both AP (`pymavlink`) and iNav (`YAMSPy`), with the MAVLink/MSP2 decoders and Invariant 7 out-of-order drop. Pulls `YAMSPy` as a new `pyproject.toml` dependency.
|
||||
2. **AZ-393 / AZ-394 / AZ-395 / AZ-396 / AZ-397** will register concrete factories with the new `register_fc_adapter` / `register_gcs_adapter` calls; this batch provides only the registry + factory contract.
|
||||
3. **Composition-root wiring of `take_off`** to use the new `build_fc_adapter` factory is still a forward action (carried over from batch 7's PASS_WITH_INFO finding #3).
|
||||
@@ -0,0 +1,85 @@
|
||||
# Batch 08 — Code Review
|
||||
|
||||
**Batch**: 8 of N
|
||||
**Tasks**: AZ-390 (FcAdapter / GcsAdapter Protocols + DTOs + factories + composition), AZ-392 (CovarianceProjector helper)
|
||||
**Reviewer**: autodev (7-phase)
|
||||
**Verdict**: **PASS_WITH_INFO**
|
||||
**Date**: 2026-05-11
|
||||
|
||||
## Scope
|
||||
|
||||
| Task | Component / Concern | Files touched (prod) | Files touched (tests) |
|
||||
|------|---------------------|----------------------|------------------------|
|
||||
| AZ-390 | C8 public Protocols + DTOs + errors + factories + Config blocks | `_types/{fc.py,emitted.py,nav.py}`, `components/c8_fc_adapter/{__init__.py,interface.py,errors.py}`, `config/{schema.py,loader.py,__init__.py}`, `runtime_root/{__init__.py,fc_factory.py}` | `tests/unit/c8_fc_adapter/{test_az390_adapter_protocol.py,test_smoke.py}`, `tests/unit/test_ac1_scaffold_layout.py` |
|
||||
| AZ-392 | C8 covariance projector | `components/c8_fc_adapter/_covariance_projector.py` | `tests/unit/c8_fc_adapter/test_az392_covariance_projector.py` |
|
||||
|
||||
## Phase 1 — AC compliance
|
||||
|
||||
| Task | ACs | Coverage |
|
||||
|------|-----|----------|
|
||||
| AZ-390 | 10 ACs (Protocol conformance, frozen-slot DTOs, enum membership, flag-OFF rejection, config-load rejection, single-writer thread, GCS factory, public-API gate, error hierarchy, INFO log) + NFR-perf | All passing in `test_az390_adapter_protocol.py` (36 tests). Public-API gate also covered by `test_smoke.py::test_internal_modules_not_in_public_all`. |
|
||||
| AZ-392 | 7 ACs (reduction correctness, AP `float m`, iNav `int mm`, SPD violation, NaN guard, bit-stability, uint16 clamp) + NFR-perf | All passing in `test_az392_covariance_projector.py` (17 tests). |
|
||||
|
||||
53 new tests added in batch; 410 total in suite (was 357), 2 pre-existing skips, 0 failures.
|
||||
|
||||
## Phase 2 — Contract drift
|
||||
|
||||
- **`composition_root_protocol.md` v1.1.0 → v1.2.0 (minor)**: added cross-cutting `fc: FcConfig` + `gcs: GcsConfig` blocks on `Config`, and `build_fc_adapter` / `build_gcs_adapter` factories on the composition-root surface. Backwards-compatible — default `FcConfig()` + `GcsConfig()` preserve existing semantics; the four new env vars per block all have documented defaults.
|
||||
- **`fc_adapter_protocol.md`** is unchanged at v1.0.0 — this batch implements the v1.0.0 surface as specified.
|
||||
|
||||
## Phase 3 — Architectural compliance
|
||||
|
||||
- **No new dependencies.** Every new module uses stdlib + numpy + pyyaml (all already pinned). YAMSPy entry is gated on AZ-391 (batch 9).
|
||||
- **Module-layout adherence**: `_covariance_projector.py` is prefixed `_` per `module-layout.md` rule "internal helpers MUST start with `_`"; `c8_fc_adapter/__init__.__all__` exposes ONLY the contract symbols (asserted by `test_smoke.test_internal_modules_not_in_public_all`).
|
||||
- **Layer 1 (helpers) discipline**: `fc_factory.py` imports only from `gps_denied_onboard.config`, `gps_denied_onboard.logging`, and `gps_denied_onboard.components.c8_fc_adapter.{errors,interface}`. No upward imports from c8 internals into the composition root.
|
||||
- **ADR-002 build-time exclusion**: the `_FC_BUILD_FLAGS` mapping is the single source of truth tying a strategy slug to its build flag. The factory consults `os.environ` (not config) because flag state is a build-time artifact per the ADR.
|
||||
- **ADR-009 interface-first DI**: all factories take `**deps` keyword args; concrete strategies are registered via `register_fc_adapter(slug, factory)` from per-binary bootstrap modules, never imported directly by the composition root.
|
||||
- **AC-NEW-3 (every payload class from t=0) preserved**: this batch did NOT wire `take_off` to call `build_fc_adapter` (the strict ordering is already enforced by batch 7's `take_off`); only the factory itself landed. The wire-up happens when the first concrete adapter (AZ-393) lands.
|
||||
- **Single-writer outbound thread (AC-6 / Invariant 8)**: `bind_outbound_emit_thread` is process-global state guarded by a single lock; idempotent on same-thread re-binds (test-isolation friendly).
|
||||
|
||||
## Phase 4 — Performance & reliability
|
||||
|
||||
- **Factory build path is O(strategy-name-lookup)**: two dict lookups + one env-var read; AC-NFR sanity check verifies < 50 ms (typically < 100 µs measured locally).
|
||||
- **CovarianceProjector is O(1) per call**: 2×2 closed-form eigenvalue, no LAPACK dispatch. `test_nfr_perf_projector_under_100us_per_call` verifies < 100 µs avg over 1k iterations.
|
||||
- **iNav clamp path emits at most one WARN + one FDR record per occurrence** — no rate-limit needed because the clamp itself is a per-frame event already gated by the projector call rate (5 Hz emit per AC-1.4).
|
||||
- **SPD violation path is allocation-light**: one numpy view (`cov_arr[:3, :3]`), one 2×2 sub-view, two scalar reads. The FDR enqueue happens BEFORE the raise so the operator gets the post-flight forensic record even if the upstream caller swallows the exception.
|
||||
|
||||
## Phase 5 — Test quality
|
||||
|
||||
- **AC-1 Protocol tests use real stub classes** (not `mock.MagicMock`) so `isinstance(x, FcAdapter)` exercises the `@runtime_checkable` protocol-fingerprint check honestly.
|
||||
- **AC-2 DTO tests parametrise over EVERY contract DTO**, asserting both frozen-instance immutability AND `__slots__` presence. Forgetting `slots=True` on a new DTO fails the matching parametrise.
|
||||
- **AC-3 enum tests assert exact member set** (not a subset) — adding an unintended enum member also fails the gate.
|
||||
- **AC-4 / AC-5 tests separate the config-load gate from the build-time gate**: unknown strategy fails at `FcConfig(...)` construction; flag-OFF fails at `build_fc_adapter(config)`; unregistered-but-known-strategy fails at `build_fc_adapter` with a clear "registered strategies: [...]" message.
|
||||
- **AC-6 cross-thread rebind test uses a real `threading.Thread`** — not just a different `thread_ident` argument — so the lock's cross-thread visibility is exercised.
|
||||
- **AC-10 INFO-log tests pin `caplog.at_level(...)` to the exact logger name** so they don't accidentally pass on unrelated INFO records elsewhere in the process.
|
||||
- **AZ-392 SPD-violation tests assert both the raise AND the FDR record** carrying `reason` + `frame_id` — a regression that drops the FDR record would not pass the raise-only test alone.
|
||||
- **AZ-392 bit-stability test calls 20 times on the same input** and asserts `len(set(results)) == 1` — drift between calls (e.g. from `numpy.eigvalsh`-style round-off) fails the gate.
|
||||
- **AZ-392 NFR-perf test uses `time.perf_counter`** with 1 k iterations and a 100 µs/call budget — sufficient headroom for jittery CI runners while still catching an order-of-magnitude regression.
|
||||
|
||||
Arrange / Act / Assert pattern consistently applied in all new tests.
|
||||
|
||||
## Phase 6 — Logging & FDR coverage
|
||||
|
||||
- **`fc_factory.build_fc_adapter`**: INFO log per build (`kind="c8.adapter.strategy_loaded"`, `kv={strategy, port_device}`).
|
||||
- **`fc_factory.build_gcs_adapter`**: INFO log per build (`kind="c8.gcs.strategy_loaded"`, `kv={strategy, port_device}`).
|
||||
- **`CovarianceProjector`**: WARN log on iNav clamp (`kind="c8.cov_projector.inav_clamped"`, `kv={radius_mm_raw, clamped_to, frame_id}`).
|
||||
- **`CovarianceProjector`**: FDR ERROR record on every projection rejection (`kind="log"`, `payload.level="ERROR"`, `payload.kind="c8.cov_projector.spd_violation"`, `payload.kv={reason, frame_id, …}`). Uses the existing `log` record kind so no FDR schema bump is needed.
|
||||
- All log records follow the `kind` + `kv` convention required by AZ-266's `JsonFormatter`.
|
||||
|
||||
## Phase 7 — Security & risk surface
|
||||
|
||||
- **RESTRICT-COMM-2 (iNav has no signing)**: enforced at config-load by `FcConfig.__post_init__` — passing `adapter="inav"` with any `signing_key_source != "none"` raises `ConfigError` with the restriction code in the message. Covered by `test_ac5_inav_signing_key_combination_rejected`.
|
||||
- **Build-flag gate uppercases the env-var value** (`os.environ.get(...).upper() == "OFF"`) so `BUILD_FC_INAV=off` is also honoured. The DEFAULT (no env var set) is ON-per-binary; concrete binaries SHOULD set it explicitly to keep the registry → flag mapping honest.
|
||||
- **Strategy registry rejects duplicate registrations with different factories** (`FcAdapterConfigError`); same-factory re-registration is idempotent. This prevents a second bootstrap module from silently overriding the first.
|
||||
- **No silent fallback**: every config-load / build / projection failure raises or emits an FDR record. The covariance-projector FDR enqueue is best-effort but logs its own `c8.cov_projector.fdr_enqueue_failed` so a silenced FDR doesn't hide the SPD failure.
|
||||
- **`bind_outbound_emit_thread` does not unbind on its own** — `clear_outbound_thread_binding()` is intentionally scoped to test-isolation. A process that wants to re-bind in production must restart, which matches the single-writer invariant.
|
||||
|
||||
## Informational findings (non-blocking)
|
||||
|
||||
1. **`take_off` is not yet wired to call `build_fc_adapter` / `build_gcs_adapter`** — this is the carry-over from batch 7's PASS_WITH_INFO finding #3. The wire-up will happen when the first concrete adapter lands (AZ-393), where the `fc_factory` can be exercised end-to-end with a real adapter. Documented in the contract bump.
|
||||
2. **`Subscription` Protocol has only `cancel()`** — the contract spec leaves open whether a `Subscription` should also be a context manager. We did NOT add `__enter__` / `__exit__` because no AC requires it and AZ-391 hasn't started consuming the type yet. If AZ-391 needs ctx-manager semantics, that's a v1.1.0 contract bump on `fc_adapter_protocol.md`, not a regression.
|
||||
3. **`_covariance_projector.py` is intentionally NOT in `c8_fc_adapter/__init__.__all__`** — concrete adapters (AZ-393 / AZ-394) instantiate it via direct module import, since it is an internal helper. The smoke test asserts the public-API gate; no contract bump required because the projector is a concrete helper, not a Protocol.
|
||||
|
||||
## Verdict
|
||||
|
||||
PASS_WITH_INFO — all ACs satisfied, all tests green, no architectural drift, one minor contract bump (`composition_root_protocol.md` 1.1.0 → 1.2.0) documented inline with migration notes. The three informational findings are forward actions tied to upcoming batches, not blockers.
|
||||
@@ -6,9 +6,9 @@ step: 7
|
||||
name: Implement
|
||||
status: in_progress
|
||||
sub_step:
|
||||
phase: 14
|
||||
name: loop-next-batch
|
||||
detail: "batch 7 of N committed"
|
||||
phase: 6
|
||||
name: implement-tasks
|
||||
detail: "batch 8 of N committed (AZ-390, AZ-392); AZ-391 deferred to batch 9 with YAMSPy"
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"""C8 outbound (FC-emitted) external-position DTO."""
|
||||
"""C8 outbound (FC-emitted) external-position DTO (AZ-390 / E-C8).
|
||||
|
||||
The DTO carries the per-emit observability bundle the FDR (C13) needs
|
||||
to reconstruct who wrote which position to the FC at what monotonic
|
||||
time. The actual lat/lon/alt that travelled over the wire is owned by
|
||||
the FC variant (AP's `GPS_INPUT.lat/lon`, iNav's
|
||||
`MSP2_SENSOR_GPS.lat/lon`) — we only need the cross-FC scalar
|
||||
``horiz_accuracy_m`` here because that field round-trips through the
|
||||
covariance projector (AZ-392) and is the only common observability
|
||||
target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from gps_denied_onboard._types.fc import FcKind
|
||||
|
||||
__all__ = ["EmittedExternalPosition"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmittedExternalPosition:
|
||||
"""A single C8-emitted external-position datum (encoded per-FC at the adapter)."""
|
||||
"""Observability record for a single C8 outbound position emit.
|
||||
|
||||
timestamp: datetime
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: float
|
||||
horizontal_accuracy_m: float
|
||||
vertical_accuracy_m: float
|
||||
Constructed by the AP / iNav outbound bodies (AZ-393 / AZ-394)
|
||||
immediately after the wire write succeeds; consumed by the
|
||||
runtime root for FDR logging.
|
||||
"""
|
||||
|
||||
fc_kind: FcKind
|
||||
horiz_accuracy_m: float
|
||||
source_label: str
|
||||
emitted_at: int
|
||||
sequence_number: int
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""C8 flight-controller adapter DTOs + enums (AZ-390 / E-C8).
|
||||
|
||||
These are the shared types across the C8 component's public Protocol
|
||||
surface (`FcAdapter`, `GcsAdapter`) — consumed by every downstream
|
||||
consumer task (AZ-391 inbound decode, AZ-392 covariance projector,
|
||||
AZ-393 AP outbound, AZ-394 iNav outbound, AZ-395 signing, AZ-396
|
||||
source-set switch, AZ-397 GCS adapter).
|
||||
|
||||
Frozen + slotted per ADR-002 / module-layout.md so the wire encoders
|
||||
and decoders cannot mutate observed telemetry. Enum integer values
|
||||
mirror the per-FC wire constants where applicable
|
||||
(``Severity`` follows MAVLink ``STATUSTEXT.severity`` semantics).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
|
||||
__all__ = [
|
||||
"AttitudeSample",
|
||||
"FcKind",
|
||||
"FcTelemetryFrame",
|
||||
"FlightState",
|
||||
"FlightStateSignal",
|
||||
"GpsHealth",
|
||||
"GpsStatus",
|
||||
"ImuTelemetrySample",
|
||||
"OperatorCommand",
|
||||
"PortConfig",
|
||||
"Severity",
|
||||
"Subscription",
|
||||
"TelemetryKind",
|
||||
]
|
||||
|
||||
|
||||
class FcKind(Enum):
|
||||
"""Concrete flight-controller variant (`config.fc.adapter`)."""
|
||||
|
||||
ARDUPILOT_PLANE = "ardupilot_plane"
|
||||
INAV = "inav"
|
||||
|
||||
|
||||
class FlightState(Enum):
|
||||
"""Coarse FC flight-state lattice consumed by C5 + C8 emit gate."""
|
||||
|
||||
INIT = "init"
|
||||
ARMED = "armed"
|
||||
IN_FLIGHT = "in_flight"
|
||||
ON_GROUND = "on_ground"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class GpsStatus(Enum):
|
||||
"""FC-reported GPS health bucket consumed by C5's spoof-recovery gate."""
|
||||
|
||||
NO_FIX = "no_fix"
|
||||
DEGRADED = "degraded"
|
||||
STABLE = "stable"
|
||||
STABLE_NON_SPOOFED = "stable_non_spoofed"
|
||||
SPOOFED = "spoofed"
|
||||
|
||||
|
||||
class Severity(Enum):
|
||||
"""STATUSTEXT severity; values mirror MAVLink ``MAV_SEVERITY``."""
|
||||
|
||||
INFO = 6
|
||||
WARNING = 4
|
||||
ERROR = 3
|
||||
|
||||
|
||||
class TelemetryKind(Enum):
|
||||
"""Discriminator for :class:`FcTelemetryFrame.payload`."""
|
||||
|
||||
IMU_SAMPLE = "imu_sample"
|
||||
ATTITUDE = "attitude"
|
||||
GPS_HEALTH = "gps_health"
|
||||
MAV_STATE = "mav_state"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PortConfig:
|
||||
"""Serial-port descriptor for C8 ``open()`` (D-C8-1)."""
|
||||
|
||||
device: str
|
||||
baud: int
|
||||
fc_kind: FcKind
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImuTelemetrySample:
|
||||
"""Single 6-axis IMU sample from the FC's onboard sensor stream.
|
||||
|
||||
Distinct from `nav.ImuSample` (the C1/C5 preintegrator's input
|
||||
type) because C8 also carries `received_at` decode-side
|
||||
monotonic_ns from Invariant 7 (out-of-order drop). The C8 inbound
|
||||
path (AZ-391) wraps this in `FcTelemetryFrame`.
|
||||
"""
|
||||
|
||||
ts_ns: int
|
||||
accel_xyz: tuple[float, float, float]
|
||||
gyro_xyz: tuple[float, float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttitudeSample:
|
||||
"""Single FC-reported attitude (RPY + monotonic timestamp)."""
|
||||
|
||||
ts_ns: int
|
||||
roll_rad: float
|
||||
pitch_rad: float
|
||||
yaw_rad: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GpsHealth:
|
||||
"""FC-reported GPS health bundle (post-decode form).
|
||||
|
||||
`captured_at` is monotonic_ns at the decode boundary
|
||||
(Invariant 7).
|
||||
"""
|
||||
|
||||
status: GpsStatus
|
||||
fix_age_ms: int
|
||||
captured_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlightStateSignal:
|
||||
"""FC's high-level flight-state lattice + AC-5.1 warm-start hint."""
|
||||
|
||||
state: FlightState
|
||||
last_valid_gps_hint_wgs84: LatLonAlt | None
|
||||
last_valid_gps_age_ms: int | None
|
||||
captured_at: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FcTelemetryFrame:
|
||||
"""Unified inbound telemetry envelope (AZ-391 producer)."""
|
||||
|
||||
kind: TelemetryKind
|
||||
payload: ImuTelemetrySample | AttitudeSample | GpsHealth | FlightStateSignal
|
||||
received_at: int
|
||||
signed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperatorCommand:
|
||||
"""Operator-issued command ingested via GcsAdapter (AZ-397 consumer)."""
|
||||
|
||||
command: str
|
||||
payload: dict[str, str | int | float | bool]
|
||||
received_at: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Subscription(Protocol):
|
||||
"""Handle returned by `FcAdapter.subscribe_telemetry` /
|
||||
`GcsAdapter.subscribe_operator_commands`.
|
||||
|
||||
Calling :meth:`cancel` removes the callback from the fan-out bus.
|
||||
Multiple cancels are no-ops. The handle is thread-safe (Invariant 8
|
||||
permits inbound callbacks on a different thread than the cancel
|
||||
site).
|
||||
"""
|
||||
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
|
||||
# Sentinel callback alias used by Protocol-side type annotations.
|
||||
TelemetryCallback = Callable[["FcTelemetryFrame"], None]
|
||||
OperatorCommandCallback = Callable[["OperatorCommand"], None]
|
||||
@@ -70,20 +70,8 @@ class AttitudeWindow:
|
||||
timestamps: tuple[datetime, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlightStateSignal:
|
||||
"""Flight-controller-reported high-level state (armed, taking off, in flight, landed, …)."""
|
||||
|
||||
state: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpsHealth:
|
||||
"""FC-reported GPS health bundle (sats, hdop, fix type, spoofing-flag, …)."""
|
||||
|
||||
fix_type: int
|
||||
satellites_visible: int
|
||||
hdop: float
|
||||
timestamp: datetime
|
||||
spoofing_flag: bool = False
|
||||
# `FlightStateSignal` and `GpsHealth` moved to ``_types/fc.py`` as part
|
||||
# of AZ-390 — they belong on C8's public Protocol surface (the contract's
|
||||
# canonical shape uses enums + monotonic_ns timestamps; the old stubs
|
||||
# from AZ-263 used `str` + `datetime` and were never wired by any
|
||||
# production producer or consumer).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""C8 FC + GCS Adapter component — Public API."""
|
||||
"""C8 FC + GCS Adapter component — Public API (AZ-390 / E-C8)."""
|
||||
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import (
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""`CovarianceProjector` — honest 6x6 -> 2x2 -> equivalent_radius (AZ-392 / E-C8).
|
||||
|
||||
The single source of truth for converting a 6x6 GTSAM ``Marginals``
|
||||
covariance from C5's ``EstimatorOutput`` into the per-FC scalar
|
||||
horizontal-accuracy field. Lives inside C8 (helper-only; not in the
|
||||
public API per ``module-layout.md``).
|
||||
|
||||
Steps per Invariant 4 + AC-4.3:
|
||||
|
||||
1. ``cov_6x6 -> cov_3x3`` — top-left 3x3 block (position sub-matrix
|
||||
in the SE(3) parameterisation [x, y, z, rx, ry, rz]).
|
||||
2. ``cov_3x3 -> cov_2x2`` — top-left 2x2 (horizontal) sub-matrix.
|
||||
3. ``equivalent_radius = sqrt(largest_eigenvalue(cov_2x2))`` with the
|
||||
closed-form solution ``sqrt(0.5 * (sigma_xx + sigma_yy + sqrt(
|
||||
(sigma_xx - sigma_yy)**2 + 4 * sigma_xy**2)))`` (bit-stable; no
|
||||
numpy ``eigvalsh`` round-off drift).
|
||||
|
||||
Non-SPD / NaN inputs raise :class:`FcEmitError` BEFORE any per-FC
|
||||
unit conversion runs (AC-6 / AC-7) and emit a single FDR record
|
||||
``kind="c8.cov_projector.spd_violation"`` carrying the offending
|
||||
``frame_id`` so C13 post-mortem tooling can correlate emit drops
|
||||
with the upstream C5 frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
|
||||
from gps_denied_onboard.fdr_client.client import FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = ["CovarianceProjector"]
|
||||
|
||||
|
||||
_SPD_VIOLATION_KIND: Final[str] = "c8.cov_projector.spd_violation"
|
||||
_INAV_CLAMPED_KIND: Final[str] = "c8.cov_projector.inav_clamped"
|
||||
# iNav MSP2_SENSOR_GPS.hPosAccuracy is a uint16 in millimetres.
|
||||
_INAV_HPOS_MAX_MM: Final[int] = 65535
|
||||
|
||||
|
||||
def _is_spd_2x2(m: np.ndarray) -> bool:
|
||||
"""Strict 2x2 SPD check: symmetric, positive determinant, positive trace."""
|
||||
if not np.allclose(m, m.T, atol=1e-9):
|
||||
return False
|
||||
a, b = float(m[0, 0]), float(m[0, 1])
|
||||
d = float(m[1, 1])
|
||||
det = a * d - b * b
|
||||
trace = a + d
|
||||
return det > 0.0 and trace > 0.0
|
||||
|
||||
|
||||
def _largest_eigenvalue_2x2(m: np.ndarray) -> float:
|
||||
"""Closed-form largest eigenvalue of a 2x2 symmetric SPD matrix.
|
||||
|
||||
The 2x2 SPD case is dominated by the closed-form
|
||||
``0.5 * (a + d + sqrt((a - d)^2 + 4 * b^2))``; numpy's
|
||||
``eigvalsh`` has the same answer up to floating-point round-off
|
||||
but allocates + dispatches to LAPACK. We prefer the closed-form
|
||||
for bit-stability on per-emit calls (AC-9 cross-FC equality
|
||||
relies on identical intermediate arithmetic).
|
||||
"""
|
||||
a = float(m[0, 0])
|
||||
b = float(m[0, 1])
|
||||
d = float(m[1, 1])
|
||||
return 0.5 * (a + d + math.sqrt((a - d) * (a - d) + 4.0 * b * b))
|
||||
|
||||
|
||||
class CovarianceProjector:
|
||||
"""Honest 6x6 -> 2x2 -> equivalent_radius projector for C8 outbound emit."""
|
||||
|
||||
def __init__(self, fdr_client: FdrClient) -> None:
|
||||
self._fdr_client = fdr_client
|
||||
self._log = get_logger("c8_fc_adapter.cov_projector")
|
||||
|
||||
def to_ardupilot_horiz_accuracy_m(self, output: EstimatorOutput) -> float:
|
||||
"""Project a 6x6 covariance to meters for AP ``GPS_INPUT.horiz_accuracy``."""
|
||||
radius_m = self._equivalent_radius_m(output)
|
||||
return radius_m
|
||||
|
||||
def to_inav_h_pos_accuracy_mm(self, output: EstimatorOutput) -> int:
|
||||
"""Project a 6x6 covariance to millimeters for iNav ``MSP2_SENSOR_GPS.hPosAccuracy``.
|
||||
|
||||
Clamps at ``_INAV_HPOS_MAX_MM`` (uint16); emits a single WARN
|
||||
log per clamp event (AC-5).
|
||||
"""
|
||||
radius_m = self._equivalent_radius_m(output)
|
||||
# Round half-up to int; built-in round() uses banker's rounding,
|
||||
# so add 0.5 + math.floor for the AC-4 spec.
|
||||
radius_mm = math.floor(radius_m * 1000.0 + 0.5)
|
||||
if radius_mm > _INAV_HPOS_MAX_MM:
|
||||
self._log.warning(
|
||||
f"c8.cov_projector.inav_clamped: {radius_mm} -> {_INAV_HPOS_MAX_MM}",
|
||||
extra={
|
||||
"kind": _INAV_CLAMPED_KIND,
|
||||
"kv": {
|
||||
"radius_mm_raw": radius_mm,
|
||||
"clamped_to": _INAV_HPOS_MAX_MM,
|
||||
"frame_id": output.frame_id,
|
||||
},
|
||||
},
|
||||
)
|
||||
return _INAV_HPOS_MAX_MM
|
||||
return radius_mm
|
||||
|
||||
def _equivalent_radius_m(self, output: EstimatorOutput) -> float:
|
||||
"""Shared 6x6 -> 3x3 -> 2x2 -> sqrt(lambda_max) reduction."""
|
||||
cov_6x6 = output.covariance_6x6
|
||||
if cov_6x6 is None:
|
||||
self._fdr_log_violation(reason="missing", frame_id=output.frame_id)
|
||||
raise FcEmitError("missing covariance from C5; refusing emit")
|
||||
cov_arr = np.asarray(cov_6x6, dtype=np.float64)
|
||||
if cov_arr.shape != (6, 6):
|
||||
self._fdr_log_violation(
|
||||
reason="bad_shape",
|
||||
frame_id=output.frame_id,
|
||||
extra={"shape": list(cov_arr.shape)},
|
||||
)
|
||||
raise FcEmitError(f"covariance_6x6 must be 6x6; got shape={cov_arr.shape}")
|
||||
if not np.all(np.isfinite(cov_arr)):
|
||||
self._fdr_log_violation(reason="nan_or_inf", frame_id=output.frame_id)
|
||||
raise FcEmitError("NaN covariance from C5; refusing emit")
|
||||
cov_3x3 = cov_arr[:3, :3]
|
||||
cov_2x2 = cov_3x3[:2, :2]
|
||||
if not _is_spd_2x2(cov_2x2):
|
||||
self._fdr_log_violation(
|
||||
reason="non_spd",
|
||||
frame_id=output.frame_id,
|
||||
extra={
|
||||
"cov_2x2": cov_2x2.tolist(),
|
||||
},
|
||||
)
|
||||
raise FcEmitError("non-SPD covariance from C5; refusing emit")
|
||||
lam = _largest_eigenvalue_2x2(cov_2x2)
|
||||
if lam <= 0.0 or not math.isfinite(lam):
|
||||
self._fdr_log_violation(
|
||||
reason="degenerate_eigenvalue",
|
||||
frame_id=output.frame_id,
|
||||
extra={"lambda": lam},
|
||||
)
|
||||
raise FcEmitError("degenerate covariance eigenvalue; refusing emit")
|
||||
return math.sqrt(lam)
|
||||
|
||||
def _fdr_log_violation(
|
||||
self,
|
||||
*,
|
||||
reason: str,
|
||||
frame_id: int,
|
||||
extra: dict | None = None,
|
||||
) -> None:
|
||||
payload: dict = {"reason": reason, "frame_id": frame_id}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
# The FDR schema closes ``kind`` to the documented set; we
|
||||
# use the ``log`` kind which carries an arbitrary ``kv`` per the
|
||||
# AZ-272 contract so this projector's WARN can survive
|
||||
# roundtrip without a schema bump.
|
||||
record = FdrRecord(
|
||||
schema_version=1,
|
||||
ts=datetime.now(tz=timezone.utc).isoformat(),
|
||||
producer_id="c8_fc_adapter",
|
||||
kind="log",
|
||||
payload={
|
||||
"level": "ERROR",
|
||||
"component": "c8_fc_adapter",
|
||||
"frame_id": frame_id,
|
||||
"kind": _SPD_VIOLATION_KIND,
|
||||
"msg": "covariance projector rejected emit",
|
||||
"kv": payload,
|
||||
},
|
||||
)
|
||||
try:
|
||||
self._fdr_client.enqueue(record)
|
||||
except Exception as exc:
|
||||
# FDR enqueue failure must not mask the SPD failure path.
|
||||
self._log.error(
|
||||
"cov_projector.fdr_enqueue_failed",
|
||||
extra={
|
||||
"kind": "c8.cov_projector.fdr_enqueue_failed",
|
||||
"kv": {"error": repr(exc), "reason": reason},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""C8 FcAdapter / GcsAdapter error hierarchy (AZ-390 / E-C8).
|
||||
|
||||
Two disjoint trees so consumers can ``except FcAdapterError`` to catch
|
||||
every flight-controller adapter failure without also catching ground
|
||||
station failures (and vice versa). Sub-classes carry the specific
|
||||
contract semantics (e.g. ``SourceSetSwitchNotSupportedError`` is a
|
||||
subclass of ``SourceSetSwitchError`` so iNav's rejection is catchable
|
||||
as either form per AC-9).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"FcAdapterConfigError",
|
||||
"FcAdapterError",
|
||||
"FcEmitError",
|
||||
"FcOpenError",
|
||||
"GcsAdapterConfigError",
|
||||
"GcsAdapterError",
|
||||
"GcsEmitError",
|
||||
"SigningHandshakeError",
|
||||
"SigningKeyExpiredError",
|
||||
"SourceSetSwitchError",
|
||||
"SourceSetSwitchNotSupportedError",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# FC adapter tree
|
||||
|
||||
|
||||
class FcAdapterError(Exception):
|
||||
"""Base class for every `FcAdapter` failure (Invariant catch-all)."""
|
||||
|
||||
|
||||
class FcOpenError(FcAdapterError):
|
||||
"""`FcAdapter.open()` failed (port unavailable, signing missing, etc.)."""
|
||||
|
||||
|
||||
class FcEmitError(FcAdapterError):
|
||||
"""`emit_external_position` / `emit_status_text` failed.
|
||||
|
||||
Raised on non-SPD / NaN covariance (Invariant 4),
|
||||
`output.smoothed == True` (Invariant 6), wire-encode failure, and
|
||||
write-side OS errors.
|
||||
"""
|
||||
|
||||
|
||||
class SigningHandshakeError(FcAdapterError):
|
||||
"""MAVLink 2.0 per-flight signing handshake failed (AP only)."""
|
||||
|
||||
|
||||
class SigningKeyExpiredError(FcAdapterError):
|
||||
"""The current per-flight signing key has rotated out under us."""
|
||||
|
||||
|
||||
class SourceSetSwitchError(FcAdapterError):
|
||||
"""`request_source_set_switch()` failed (ACK timeout, REJECTED, etc.)."""
|
||||
|
||||
|
||||
class SourceSetSwitchNotSupportedError(SourceSetSwitchError):
|
||||
"""`request_source_set_switch()` is not implementable for this FC variant.
|
||||
|
||||
Raised by `Msp2InavAdapter` (iNav has no equivalent of
|
||||
`MAV_CMD_SET_EKF_SOURCE_SET`). Sub-classes `SourceSetSwitchError`
|
||||
so callers can either catch the specific case or treat it as a
|
||||
generic switch failure (AC-9).
|
||||
"""
|
||||
|
||||
|
||||
class FcAdapterConfigError(FcAdapterError):
|
||||
"""Bad / mismatched config for an FC adapter.
|
||||
|
||||
Raised at config-load for unknown strategy names (AC-5) and at
|
||||
factory build for build-flag-OFF strategies (AC-4); also by
|
||||
`Msp2InavAdapter.open(...)` when a non-None `signing_key` is
|
||||
passed (Invariant 2 — iNav has no signing per RESTRICT-COMM-2).
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# GCS adapter tree
|
||||
|
||||
|
||||
class GcsAdapterError(Exception):
|
||||
"""Base class for every `GcsAdapter` failure."""
|
||||
|
||||
|
||||
class GcsEmitError(GcsAdapterError):
|
||||
"""`GcsAdapter.emit_summary` / `emit_status_text` failed."""
|
||||
|
||||
|
||||
class GcsAdapterConfigError(GcsAdapterError):
|
||||
"""Bad / mismatched config for a GCS adapter.
|
||||
|
||||
Raised at config-load for unknown strategy names and at factory
|
||||
build for build-flag-OFF strategies.
|
||||
"""
|
||||
@@ -1,47 +1,85 @@
|
||||
"""C8 Adapter Protocols: `FcAdapter`, `GcsAdapter`, `ReplaySink`.
|
||||
"""C8 `FcAdapter` + `GcsAdapter` Protocols (AZ-390 / E-C8).
|
||||
|
||||
Concrete impls: `PymavlinkArdupilotAdapter`, `Msp2InavAdapter`,
|
||||
`MavlinkGcsAdapter`, `TlogReplayFcAdapter`, `JsonlReplaySink`. See
|
||||
`_docs/02_document/components/10_c8_fc_adapter/`.
|
||||
Concrete strategies (linked at build time per ADR-002):
|
||||
- AP: `PymavlinkArdupilotAdapter` (AZ-393 outbound, AZ-391 inbound, AZ-395 signing)
|
||||
- iNav: `Msp2InavAdapter` (AZ-394 outbound, AZ-391 inbound)
|
||||
- GCS: `QgcTelemetryAdapter` (AZ-397)
|
||||
|
||||
Replay extensions (`TlogReplayFcAdapter`, `JsonlReplaySink`) implement
|
||||
the same Protocols and live under separate build flags (E-DEMO-REPLAY).
|
||||
|
||||
Public-API restriction: only `FcAdapter`, `GcsAdapter`, `ReplaySink`,
|
||||
plus the contract DTOs in `_types/fc.py` and `_types/emitted.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Protocol
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
from gps_denied_onboard._types.nav import (
|
||||
AttitudeWindow,
|
||||
from gps_denied_onboard._types.fc import (
|
||||
FlightStateSignal,
|
||||
GpsHealth,
|
||||
ImuSample,
|
||||
OperatorCommandCallback,
|
||||
PortConfig,
|
||||
Severity,
|
||||
Subscription,
|
||||
TelemetryCallback,
|
||||
)
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
|
||||
__all__ = ["FcAdapter", "GcsAdapter", "ReplaySink"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FcAdapter(Protocol):
|
||||
"""Bidirectional flight-controller adapter."""
|
||||
"""Per-FC flight-controller adapter (inbound telemetry + outbound emit).
|
||||
|
||||
def outbound(self, position: EmittedExternalPosition) -> None: ...
|
||||
Invariants enforced by concrete implementations (see contract):
|
||||
- Single open per instance; re-open raises `FcOpenError`.
|
||||
- `close()` is idempotent.
|
||||
- `emit_external_position` rejects `output.smoothed == True`.
|
||||
- Outbound methods are called from one dedicated emit thread.
|
||||
- Inbound subscribe-callbacks fire on the decoder thread.
|
||||
"""
|
||||
|
||||
def inbound_imu(self) -> Iterator[ImuSample]: ...
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
|
||||
|
||||
def inbound_attitude(self) -> Iterator[AttitudeWindow]: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
def inbound_gps_health(self) -> Iterator[GpsHealth]: ...
|
||||
def subscribe_telemetry(self, callback: TelemetryCallback) -> Subscription: ...
|
||||
|
||||
def inbound_flight_state(self) -> Iterator[FlightStateSignal]: ...
|
||||
def emit_external_position(self, output: EstimatorOutput) -> EmittedExternalPosition: ...
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
|
||||
def request_source_set_switch(self) -> None: ...
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GcsAdapter(Protocol):
|
||||
"""Ground-control-station adapter (telemetry + operator commands)."""
|
||||
"""Ground-control-station adapter (downsampled summary + operator commands)."""
|
||||
|
||||
def emit_summary(self, summary: dict) -> None: ...
|
||||
def open(self, port: PortConfig) -> None: ...
|
||||
|
||||
def operator_commands(self) -> Iterator[dict]: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
def emit_summary(self, output: EstimatorOutput) -> None: ...
|
||||
|
||||
def subscribe_operator_commands(self, callback: OperatorCommandCallback) -> Subscription: ...
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReplaySink(Protocol):
|
||||
"""Replay-mode estimate sink (e.g. JSONL writer)."""
|
||||
"""Replay-mode estimate sink (e.g. JSONL writer).
|
||||
|
||||
def write(self, estimate: dict) -> None: ...
|
||||
Lives in the same module so the replay binary's composition root
|
||||
can wire `JsonlReplaySink` alongside the production adapters.
|
||||
Excluded from `__init__.__all__` in production-only builds via the
|
||||
`BUILD_REPLAY_SINK_JSONL` flag.
|
||||
"""
|
||||
|
||||
def write(self, output: EstimatorOutput) -> None: ...
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
from gps_denied_onboard.config.loader import ENV_KEY_MAP, load_config
|
||||
from gps_denied_onboard.config.schema import (
|
||||
DEFAULT_FORBIDDEN_RECORD_KINDS,
|
||||
KNOWN_FC_STRATEGIES,
|
||||
KNOWN_GCS_STRATEGIES,
|
||||
Config,
|
||||
ConfigError,
|
||||
FcConfig,
|
||||
FdrConfig,
|
||||
FdrWriterConfig,
|
||||
GcsConfig,
|
||||
LogConfig,
|
||||
RecordKindPolicyConfig,
|
||||
RequiredFieldMissingError,
|
||||
@@ -18,10 +22,14 @@ from gps_denied_onboard.config.schema import (
|
||||
__all__ = [
|
||||
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
||||
"ENV_KEY_MAP",
|
||||
"KNOWN_FC_STRATEGIES",
|
||||
"KNOWN_GCS_STRATEGIES",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"FcConfig",
|
||||
"FdrConfig",
|
||||
"FdrWriterConfig",
|
||||
"GcsConfig",
|
||||
"LogConfig",
|
||||
"RecordKindPolicyConfig",
|
||||
"RequiredFieldMissingError",
|
||||
|
||||
@@ -23,7 +23,9 @@ import yaml
|
||||
from gps_denied_onboard.config.schema import (
|
||||
_COMPONENT_REGISTRY,
|
||||
Config,
|
||||
FcConfig,
|
||||
FdrConfig,
|
||||
GcsConfig,
|
||||
LogConfig,
|
||||
RequiredFieldMissingError,
|
||||
RuntimeConfig,
|
||||
@@ -49,6 +51,15 @@ ENV_KEY_MAP: Final[dict[str, tuple[str, str]]] = {
|
||||
"LOG_SINK": ("log", "sink"),
|
||||
"FDR_PATH": ("fdr", "path"),
|
||||
"FDR_QUEUE_SIZE": ("fdr", "queue_size"),
|
||||
# C8 FC + GCS adapter blocks (AZ-390)
|
||||
"FC_ADAPTER": ("fc", "adapter"),
|
||||
"FC_PORT_DEVICE": ("fc", "port_device"),
|
||||
"FC_PORT_BAUD": ("fc", "port_baud"),
|
||||
"FC_SIGNING_KEY_SOURCE": ("fc", "signing_key_source"),
|
||||
"GCS_ADAPTER": ("gcs", "adapter"),
|
||||
"GCS_PORT_DEVICE": ("gcs", "port_device"),
|
||||
"GCS_PORT_BAUD": ("gcs", "port_baud"),
|
||||
"GCS_SUMMARY_RATE_HZ": ("gcs", "summary_rate_hz"),
|
||||
}
|
||||
|
||||
# Env vars that MUST resolve to a non-empty value before `load_config`
|
||||
@@ -81,6 +92,12 @@ _FIELD_COERCIONS: Final[dict[str, type]] = {
|
||||
"inference_backend": str,
|
||||
"tile_cache_path": str,
|
||||
"overrun_policy": str,
|
||||
# C8 FC + GCS adapter coercions (AZ-390)
|
||||
"adapter": str,
|
||||
"port_device": str,
|
||||
"port_baud": int,
|
||||
"signing_key_source": str,
|
||||
"summary_rate_hz": float,
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +177,14 @@ def load_config(
|
||||
FdrConfig(),
|
||||
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("fdr", {}).items()},
|
||||
)
|
||||
fc_block = _replace_block(
|
||||
FcConfig(),
|
||||
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("fc", {}).items()},
|
||||
)
|
||||
gcs_block = _replace_block(
|
||||
GcsConfig(),
|
||||
{k: _coerce_value(k, v) for k, v in yaml_overrides.get("gcs", {}).items()},
|
||||
)
|
||||
|
||||
component_blocks = _resolve_component_blocks()
|
||||
for slug, dataclass_type in _COMPONENT_REGISTRY.items():
|
||||
@@ -174,5 +199,7 @@ def load_config(
|
||||
runtime=runtime_block,
|
||||
log=log_block,
|
||||
fdr=fdr_block,
|
||||
fc=fc_block,
|
||||
gcs=gcs_block,
|
||||
components=component_blocks,
|
||||
)
|
||||
|
||||
@@ -16,10 +16,14 @@ from typing import Any, Final
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_FORBIDDEN_RECORD_KINDS",
|
||||
"KNOWN_FC_STRATEGIES",
|
||||
"KNOWN_GCS_STRATEGIES",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"FcConfig",
|
||||
"FdrConfig",
|
||||
"FdrWriterConfig",
|
||||
"GcsConfig",
|
||||
"LogConfig",
|
||||
"RecordKindPolicyConfig",
|
||||
"RequiredFieldMissingError",
|
||||
@@ -29,6 +33,10 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
KNOWN_FC_STRATEGIES: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
|
||||
KNOWN_GCS_STRATEGIES: Final[frozenset[str]] = frozenset({"qgc_mavlink"})
|
||||
|
||||
|
||||
# Default raw-frame kinds that AZ-295's RecordKindPolicy must reject
|
||||
# synchronously at the producer call site. Removing any of these from
|
||||
# a Config requires an explicit `unsafe_remove_default_forbidden=True`
|
||||
@@ -181,6 +189,67 @@ class FdrConfig:
|
||||
record_policy: RecordKindPolicyConfig = field(default_factory=RecordKindPolicyConfig)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FcConfig:
|
||||
"""C8 flight-controller adapter block (AZ-390 / E-C8).
|
||||
|
||||
``adapter`` selects one of :data:`KNOWN_FC_STRATEGIES`; unknown
|
||||
strategy names are rejected at Config construction (AC-5). The
|
||||
build-time flag check (`BUILD_FC_<VARIANT>`) happens in the
|
||||
factory itself per AC-4 because flag state lives in the process
|
||||
env, not in the config object.
|
||||
|
||||
``signing_key_source`` is one of ``"none"`` (iNav default) or
|
||||
``"ephemeral_per_flight"`` (AP default; AZ-395 owns the body).
|
||||
"""
|
||||
|
||||
adapter: str = "ardupilot_plane"
|
||||
port_device: str = "/dev/ttyTHS1"
|
||||
port_baud: int = 921600
|
||||
signing_key_source: str = "ephemeral_per_flight"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.adapter not in KNOWN_FC_STRATEGIES:
|
||||
raise ConfigError(
|
||||
f"FcConfig.adapter={self.adapter!r} not in {sorted(KNOWN_FC_STRATEGIES)}"
|
||||
)
|
||||
if self.signing_key_source not in {"none", "ephemeral_per_flight"}:
|
||||
raise ConfigError(
|
||||
f"FcConfig.signing_key_source={self.signing_key_source!r} not in "
|
||||
f"['none', 'ephemeral_per_flight']"
|
||||
)
|
||||
if self.adapter == "inav" and self.signing_key_source != "none":
|
||||
raise ConfigError(
|
||||
"FcConfig.signing_key_source must be 'none' when adapter='inav' "
|
||||
"(RESTRICT-COMM-2 — iNav has no signing)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GcsConfig:
|
||||
"""C8 GCS adapter block (AZ-390 / E-C8 — AZ-397 builds the body).
|
||||
|
||||
``adapter`` selects one of :data:`KNOWN_GCS_STRATEGIES`.
|
||||
``summary_rate_hz`` is the per-emitter downsample target
|
||||
(Invariant 12; default 2 Hz; range [1, 2]).
|
||||
"""
|
||||
|
||||
adapter: str = "qgc_mavlink"
|
||||
port_device: str = "/dev/ttyTHS2"
|
||||
port_baud: int = 921600
|
||||
summary_rate_hz: float = 2.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.adapter not in KNOWN_GCS_STRATEGIES:
|
||||
raise ConfigError(
|
||||
f"GcsConfig.adapter={self.adapter!r} not in {sorted(KNOWN_GCS_STRATEGIES)}"
|
||||
)
|
||||
if not (1.0 <= self.summary_rate_hz <= 2.0):
|
||||
raise ConfigError(
|
||||
f"GcsConfig.summary_rate_hz must be in [1.0, 2.0]; got {self.summary_rate_hz}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeConfig:
|
||||
"""Top-level runtime descriptors that don't belong to a single component."""
|
||||
@@ -200,6 +269,8 @@ _DEFAULT_BLOCKS: Final[dict[str, type]] = {
|
||||
"log": LogConfig,
|
||||
"fdr": FdrConfig,
|
||||
"runtime": RuntimeConfig,
|
||||
"fc": FcConfig,
|
||||
"gcs": GcsConfig,
|
||||
}
|
||||
|
||||
|
||||
@@ -248,20 +319,31 @@ class Config:
|
||||
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
|
||||
log: LogConfig = field(default_factory=LogConfig)
|
||||
fdr: FdrConfig = field(default_factory=FdrConfig)
|
||||
fc: FcConfig = field(default_factory=FcConfig)
|
||||
gcs: GcsConfig = field(default_factory=GcsConfig)
|
||||
components: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def with_blocks(cls, **blocks: Any) -> Config:
|
||||
"""Build a `Config` from a flat name-to-instance map.
|
||||
|
||||
Cross-cutting names (``log``, ``fdr``, ``runtime``) become attributes;
|
||||
every other key is treated as a component slug and goes into
|
||||
``components``.
|
||||
Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``, ``gcs``)
|
||||
become attributes; every other key is treated as a component slug
|
||||
and goes into ``components``.
|
||||
"""
|
||||
runtime = blocks.pop("runtime", RuntimeConfig())
|
||||
log = blocks.pop("log", LogConfig())
|
||||
fdr = blocks.pop("fdr", FdrConfig())
|
||||
return cls(runtime=runtime, log=log, fdr=fdr, components=dict(blocks))
|
||||
fc = blocks.pop("fc", FcConfig())
|
||||
gcs = blocks.pop("gcs", GcsConfig())
|
||||
return cls(
|
||||
runtime=runtime,
|
||||
log=log,
|
||||
fdr=fdr,
|
||||
fc=fc,
|
||||
gcs=gcs,
|
||||
components=dict(blocks),
|
||||
)
|
||||
|
||||
|
||||
def _block_field_names(block: Any) -> tuple[str, ...]:
|
||||
|
||||
+22
@@ -24,6 +24,18 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
||||
|
||||
from gps_denied_onboard.config import Config, load_config
|
||||
from gps_denied_onboard.runtime_root.fc_factory import (
|
||||
OutboundThreadAlreadyBoundError,
|
||||
bind_outbound_emit_thread,
|
||||
build_fc_adapter,
|
||||
build_gcs_adapter,
|
||||
clear_outbound_thread_binding,
|
||||
clear_strategy_registries,
|
||||
list_registered_fc_strategies,
|
||||
list_registered_gcs_strategies,
|
||||
register_fc_adapter,
|
||||
register_gcs_adapter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
|
||||
@@ -35,16 +47,26 @@ __all__ = [
|
||||
"REQUIRED_ENV_VARS",
|
||||
"ConfigurationError",
|
||||
"OperatorRoot",
|
||||
"OutboundThreadAlreadyBoundError",
|
||||
"RuntimeRoot",
|
||||
"StrategyNotLinkedError",
|
||||
"StrategyTier",
|
||||
"TakeoffResult",
|
||||
"bind_outbound_emit_thread",
|
||||
"build_fc_adapter",
|
||||
"build_gcs_adapter",
|
||||
"clear_outbound_thread_binding",
|
||||
"clear_strategy_registries",
|
||||
"clear_strategy_registry",
|
||||
"compose_operator",
|
||||
"compose_replay",
|
||||
"compose_root",
|
||||
"list_registered_fc_strategies",
|
||||
"list_registered_gcs_strategies",
|
||||
"list_registered_strategies",
|
||||
"main",
|
||||
"register_fc_adapter",
|
||||
"register_gcs_adapter",
|
||||
"register_strategy",
|
||||
"take_off",
|
||||
]
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Composition-root factories for C8 (AZ-390 / E-C8).
|
||||
|
||||
Lazy-imports the per-variant adapter classes so the ADR-002 build-flag
|
||||
gate stays honest: the binary's bootstrap (one module per
|
||||
``BUILD_FC_*`` / ``BUILD_GCS_*`` combination) registers the concrete
|
||||
strategy via :func:`register_fc_adapter` / :func:`register_gcs_adapter`
|
||||
ahead of `build_fc_adapter` / `build_gcs_adapter`.
|
||||
|
||||
A second binding to the outbound emit thread is rejected (AC-6); the
|
||||
single-writer invariant for outbound is enforced statically by the
|
||||
composition root, not by the adapter itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Final
|
||||
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
GcsAdapterConfigError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import (
|
||||
FcAdapter,
|
||||
GcsAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = [
|
||||
"OutboundThreadAlreadyBoundError",
|
||||
"bind_outbound_emit_thread",
|
||||
"build_fc_adapter",
|
||||
"build_gcs_adapter",
|
||||
"clear_outbound_thread_binding",
|
||||
"clear_strategy_registries",
|
||||
"list_registered_fc_strategies",
|
||||
"list_registered_gcs_strategies",
|
||||
"register_fc_adapter",
|
||||
"register_gcs_adapter",
|
||||
]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Strategy registries (single source of truth; populated by binary
|
||||
# bootstrap modules per ADR-002).
|
||||
|
||||
FcAdapterFactory = Callable[..., FcAdapter]
|
||||
GcsAdapterFactory = Callable[..., GcsAdapter]
|
||||
|
||||
_FC_REGISTRY: dict[str, FcAdapterFactory] = {}
|
||||
_GCS_REGISTRY: dict[str, GcsAdapterFactory] = {}
|
||||
|
||||
# Mapping from strategy slug -> documented BUILD_*_ flag name. The
|
||||
# build-flag gate (AC-4) checks ``os.environ`` for the canonical name
|
||||
# because flag state is a build-time artifact, not a config-time
|
||||
# artifact.
|
||||
_FC_BUILD_FLAGS: Final[dict[str, str]] = {
|
||||
"ardupilot_plane": "BUILD_FC_ARDUPILOT_PLANE",
|
||||
"inav": "BUILD_FC_INAV",
|
||||
}
|
||||
_GCS_BUILD_FLAGS: Final[dict[str, str]] = {
|
||||
"qgc_mavlink": "BUILD_GCS_QGC_MAVLINK",
|
||||
}
|
||||
|
||||
|
||||
def register_fc_adapter(strategy: str, factory: FcAdapterFactory) -> None:
|
||||
"""Register a concrete `FcAdapter` strategy.
|
||||
|
||||
Called from the per-binary bootstrap module (e.g.
|
||||
``runtime_root._bootstrap_ap.py``) under the matching
|
||||
``BUILD_FC_<VARIANT>`` flag. Duplicate registration with a
|
||||
different factory is a build error.
|
||||
"""
|
||||
existing = _FC_REGISTRY.get(strategy)
|
||||
if existing is not None and existing is not factory:
|
||||
raise FcAdapterConfigError(f"duplicate FcAdapter registration for strategy {strategy!r}")
|
||||
_FC_REGISTRY[strategy] = factory
|
||||
|
||||
|
||||
def register_gcs_adapter(strategy: str, factory: GcsAdapterFactory) -> None:
|
||||
existing = _GCS_REGISTRY.get(strategy)
|
||||
if existing is not None and existing is not factory:
|
||||
raise GcsAdapterConfigError(f"duplicate GcsAdapter registration for strategy {strategy!r}")
|
||||
_GCS_REGISTRY[strategy] = factory
|
||||
|
||||
|
||||
def clear_strategy_registries() -> None:
|
||||
"""Reset both registries; intended for unit-test isolation only."""
|
||||
_FC_REGISTRY.clear()
|
||||
_GCS_REGISTRY.clear()
|
||||
|
||||
|
||||
def list_registered_fc_strategies() -> list[str]:
|
||||
return sorted(_FC_REGISTRY)
|
||||
|
||||
|
||||
def list_registered_gcs_strategies() -> list[str]:
|
||||
return sorted(_GCS_REGISTRY)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Single-writer outbound thread enforcement (Invariant 8 / AC-6).
|
||||
|
||||
|
||||
class OutboundThreadAlreadyBoundError(RuntimeError):
|
||||
"""Raised on a second :func:`bind_outbound_emit_thread` call."""
|
||||
|
||||
|
||||
_outbound_lock = threading.Lock()
|
||||
_outbound_bound_thread: int | None = None
|
||||
|
||||
|
||||
def bind_outbound_emit_thread(thread_ident: int | None = None) -> int:
|
||||
"""Bind ``thread_ident`` (defaults to the caller) as the sole emit thread.
|
||||
|
||||
A second call from any thread raises
|
||||
:class:`OutboundThreadAlreadyBoundError`. The runtime root calls
|
||||
this once per process before wiring outbound emit; the result is
|
||||
the canonical thread id the adapter checks on every outbound call.
|
||||
"""
|
||||
global _outbound_bound_thread
|
||||
ident = thread_ident if thread_ident is not None else threading.get_ident()
|
||||
with _outbound_lock:
|
||||
if _outbound_bound_thread is not None and _outbound_bound_thread != ident:
|
||||
raise OutboundThreadAlreadyBoundError(
|
||||
f"outbound emit thread already bound to {_outbound_bound_thread}; "
|
||||
f"refused to re-bind to {ident}"
|
||||
)
|
||||
_outbound_bound_thread = ident
|
||||
return ident
|
||||
|
||||
|
||||
def clear_outbound_thread_binding() -> None:
|
||||
"""Reset the outbound-thread binding; intended for unit-test isolation."""
|
||||
global _outbound_bound_thread
|
||||
with _outbound_lock:
|
||||
_outbound_bound_thread = None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Build helpers — invoked by `compose_root` after C5 (FC) and after
|
||||
# the FC adapter (GCS).
|
||||
|
||||
|
||||
def build_fc_adapter(config: Config, **deps: Any) -> FcAdapter:
|
||||
"""Resolve and build the configured `FcAdapter` strategy.
|
||||
|
||||
Validates the build-flag gate (AC-4); raises
|
||||
:class:`FcAdapterConfigError` with the disabled-flag name when the
|
||||
requested strategy is not linked into the running binary.
|
||||
"""
|
||||
strategy = config.fc.adapter
|
||||
flag_name = _FC_BUILD_FLAGS.get(strategy)
|
||||
if flag_name is None:
|
||||
# config.fc.adapter went through FcConfig validation, so an
|
||||
# unknown strategy here means we forgot to add it to the
|
||||
# build-flag table — fail loudly.
|
||||
raise FcAdapterConfigError(f"FC strategy {strategy!r} has no BUILD_FC_* flag mapping")
|
||||
if os.environ.get(flag_name, "ON").upper() == "OFF":
|
||||
raise FcAdapterConfigError(
|
||||
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
|
||||
)
|
||||
factory = _FC_REGISTRY.get(strategy)
|
||||
if factory is None:
|
||||
raise FcAdapterConfigError(
|
||||
f"FC strategy {strategy!r} is selected by config.fc.adapter but "
|
||||
f"not registered; registered strategies: "
|
||||
f"{list_registered_fc_strategies()}"
|
||||
)
|
||||
adapter = factory(config=config, **deps)
|
||||
_log_strategy_loaded(
|
||||
kind="c8.adapter.strategy_loaded",
|
||||
strategy=strategy,
|
||||
port_device=config.fc.port_device,
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def build_gcs_adapter(config: Config, **deps: Any) -> GcsAdapter:
|
||||
"""Resolve and build the configured `GcsAdapter` strategy (AC-7)."""
|
||||
strategy = config.gcs.adapter
|
||||
flag_name = _GCS_BUILD_FLAGS.get(strategy)
|
||||
if flag_name is None:
|
||||
raise GcsAdapterConfigError(f"GCS strategy {strategy!r} has no BUILD_GCS_* flag mapping")
|
||||
if os.environ.get(flag_name, "ON").upper() == "OFF":
|
||||
raise GcsAdapterConfigError(
|
||||
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
|
||||
)
|
||||
factory = _GCS_REGISTRY.get(strategy)
|
||||
if factory is None:
|
||||
raise GcsAdapterConfigError(
|
||||
f"GCS strategy {strategy!r} is selected by config.gcs.adapter but "
|
||||
f"not registered; registered strategies: "
|
||||
f"{list_registered_gcs_strategies()}"
|
||||
)
|
||||
adapter = factory(config=config, **deps)
|
||||
_log_strategy_loaded(
|
||||
kind="c8.gcs.strategy_loaded",
|
||||
strategy=strategy,
|
||||
port_device=config.gcs.port_device,
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _log_strategy_loaded(*, kind: str, strategy: str, port_device: str) -> None:
|
||||
log = get_logger("runtime_root.fc_factory")
|
||||
log.info(
|
||||
f"{kind}: strategy={strategy} port_device={port_device}",
|
||||
extra={
|
||||
"kind": kind,
|
||||
"kv": {"strategy": strategy, "port_device": port_device},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,513 @@
|
||||
"""AZ-390 — FcAdapter + GcsAdapter Protocols + DTOs + factories + composition.
|
||||
|
||||
Covers all 10 ACs:
|
||||
1. Protocol conformance via @runtime_checkable
|
||||
2. DTOs frozen + slots
|
||||
3. Enum membership
|
||||
4. Factory rejects build-flag OFF
|
||||
5. Factory rejects unknown strategy at config-load
|
||||
6. Single-writer outbound thread
|
||||
7. GcsAdapter factory parallel coverage
|
||||
8. Public API re-exports
|
||||
9. Error hierarchy catchability
|
||||
10. INFO log on build
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.emitted import EmittedExternalPosition
|
||||
from gps_denied_onboard._types.fc import (
|
||||
AttitudeSample,
|
||||
FcKind,
|
||||
FcTelemetryFrame,
|
||||
FlightState,
|
||||
FlightStateSignal,
|
||||
GpsHealth,
|
||||
GpsStatus,
|
||||
ImuTelemetrySample,
|
||||
OperatorCommand,
|
||||
PortConfig,
|
||||
Severity,
|
||||
Subscription,
|
||||
TelemetryKind,
|
||||
)
|
||||
from gps_denied_onboard._types.pose import EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter import (
|
||||
FcAdapter,
|
||||
GcsAdapter,
|
||||
ReplaySink,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
FcAdapterError,
|
||||
FcEmitError,
|
||||
FcOpenError,
|
||||
GcsAdapterConfigError,
|
||||
GcsAdapterError,
|
||||
GcsEmitError,
|
||||
SigningHandshakeError,
|
||||
SourceSetSwitchError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
)
|
||||
from gps_denied_onboard.config import Config, ConfigError, FcConfig, GcsConfig
|
||||
from gps_denied_onboard.runtime_root.fc_factory import (
|
||||
OutboundThreadAlreadyBoundError,
|
||||
bind_outbound_emit_thread,
|
||||
build_fc_adapter,
|
||||
build_gcs_adapter,
|
||||
clear_outbound_thread_binding,
|
||||
clear_strategy_registries,
|
||||
register_fc_adapter,
|
||||
register_gcs_adapter,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_factory_state() -> None:
|
||||
# Arrange — every test starts from a clean registry + thread binding.
|
||||
clear_strategy_registries()
|
||||
clear_outbound_thread_binding()
|
||||
yield
|
||||
clear_strategy_registries()
|
||||
clear_outbound_thread_binding()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: Protocol conformance
|
||||
|
||||
|
||||
class _FcStub:
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
def subscribe_telemetry(self, callback: Callable[[FcTelemetryFrame], None]) -> Subscription:
|
||||
class _Sub:
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
return _Sub()
|
||||
|
||||
def emit_external_position(self, output: EstimatorOutput) -> EmittedExternalPosition:
|
||||
return EmittedExternalPosition(
|
||||
fc_kind=FcKind.ARDUPILOT_PLANE,
|
||||
horiz_accuracy_m=1.0,
|
||||
source_label=output.source_label,
|
||||
emitted_at=0,
|
||||
sequence_number=0,
|
||||
)
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
def request_source_set_switch(self) -> None: ...
|
||||
def current_flight_state(self) -> FlightStateSignal:
|
||||
return FlightStateSignal(
|
||||
state=FlightState.INIT,
|
||||
last_valid_gps_hint_wgs84=None,
|
||||
last_valid_gps_age_ms=None,
|
||||
captured_at=0,
|
||||
)
|
||||
|
||||
|
||||
class _GcsStub:
|
||||
def open(self, port: PortConfig) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
def emit_summary(self, output: EstimatorOutput) -> None: ...
|
||||
def subscribe_operator_commands(
|
||||
self, callback: Callable[[OperatorCommand], None]
|
||||
) -> Subscription:
|
||||
class _Sub:
|
||||
def cancel(self) -> None: ...
|
||||
|
||||
return _Sub()
|
||||
|
||||
def emit_status_text(self, msg: str, severity: Severity) -> None: ...
|
||||
|
||||
|
||||
def test_ac1_fc_protocol_conformance() -> None:
|
||||
# Assert
|
||||
assert isinstance(_FcStub(), FcAdapter)
|
||||
|
||||
|
||||
def test_ac1_gcs_protocol_conformance() -> None:
|
||||
# Assert
|
||||
assert isinstance(_GcsStub(), GcsAdapter)
|
||||
|
||||
|
||||
def test_ac1_replay_sink_protocol_conformance() -> None:
|
||||
# Arrange
|
||||
class _Sink:
|
||||
def write(self, output: EstimatorOutput) -> None: ...
|
||||
|
||||
# Assert
|
||||
assert isinstance(_Sink(), ReplaySink)
|
||||
|
||||
|
||||
def test_ac1_protocol_rejects_missing_method() -> None:
|
||||
# Arrange
|
||||
class _Incomplete:
|
||||
def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
# missing the other methods
|
||||
|
||||
# Assert
|
||||
assert not isinstance(_Incomplete(), FcAdapter)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: DTOs frozen + slots
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dto, init_kwargs",
|
||||
[
|
||||
(PortConfig, {"device": "/dev/ttyTHS1", "baud": 921600, "fc_kind": FcKind.ARDUPILOT_PLANE}),
|
||||
(
|
||||
ImuTelemetrySample,
|
||||
{"ts_ns": 0, "accel_xyz": (0.0, 0.0, 0.0), "gyro_xyz": (0.0, 0.0, 0.0)},
|
||||
),
|
||||
(AttitudeSample, {"ts_ns": 0, "roll_rad": 0.0, "pitch_rad": 0.0, "yaw_rad": 0.0}),
|
||||
(GpsHealth, {"status": GpsStatus.STABLE, "fix_age_ms": 0, "captured_at": 0}),
|
||||
(
|
||||
FlightStateSignal,
|
||||
{
|
||||
"state": FlightState.INIT,
|
||||
"last_valid_gps_hint_wgs84": None,
|
||||
"last_valid_gps_age_ms": None,
|
||||
"captured_at": 0,
|
||||
},
|
||||
),
|
||||
(OperatorCommand, {"command": "test", "payload": {}, "received_at": 0}),
|
||||
(
|
||||
EmittedExternalPosition,
|
||||
{
|
||||
"fc_kind": FcKind.ARDUPILOT_PLANE,
|
||||
"horiz_accuracy_m": 1.0,
|
||||
"source_label": "visual_propagated",
|
||||
"emitted_at": 0,
|
||||
"sequence_number": 0,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_ac2_dto_frozen_and_slotted(dto: type, init_kwargs: dict) -> None:
|
||||
# Arrange
|
||||
instance = dto(**init_kwargs)
|
||||
|
||||
# Assert — frozen
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
any_field = next(iter(init_kwargs))
|
||||
setattr(instance, any_field, init_kwargs[any_field])
|
||||
|
||||
# Assert — slots
|
||||
assert hasattr(dto, "__slots__")
|
||||
assert len(dto.__slots__) > 0
|
||||
|
||||
|
||||
def test_ac2_fc_telemetry_frame_dto_frozen() -> None:
|
||||
# Arrange
|
||||
frame = FcTelemetryFrame(
|
||||
kind=TelemetryKind.IMU_SAMPLE,
|
||||
payload=ImuTelemetrySample(ts_ns=0, accel_xyz=(0.0, 0.0, 0.0), gyro_xyz=(0.0, 0.0, 0.0)),
|
||||
received_at=0,
|
||||
signed=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
frame.signed = True # type: ignore[misc]
|
||||
assert hasattr(FcTelemetryFrame, "__slots__")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: Enum membership
|
||||
|
||||
|
||||
def test_ac3_fc_kind_has_two_members() -> None:
|
||||
# Assert
|
||||
assert {m.name for m in FcKind} == {"ARDUPILOT_PLANE", "INAV"}
|
||||
|
||||
|
||||
def test_ac3_flight_state_has_five_members() -> None:
|
||||
# Assert
|
||||
assert {m.name for m in FlightState} == {"INIT", "ARMED", "IN_FLIGHT", "ON_GROUND", "FAILED"}
|
||||
|
||||
|
||||
def test_ac3_gps_status_has_five_members() -> None:
|
||||
# Assert
|
||||
assert {m.name for m in GpsStatus} == {
|
||||
"NO_FIX",
|
||||
"DEGRADED",
|
||||
"STABLE",
|
||||
"STABLE_NON_SPOOFED",
|
||||
"SPOOFED",
|
||||
}
|
||||
|
||||
|
||||
def test_ac3_severity_values_mirror_mavlink() -> None:
|
||||
# Assert
|
||||
assert Severity.INFO.value == 6
|
||||
assert Severity.WARNING.value == 4
|
||||
assert Severity.ERROR.value == 3
|
||||
|
||||
|
||||
def test_ac3_telemetry_kind_has_four_members() -> None:
|
||||
# Assert
|
||||
assert {m.name for m in TelemetryKind} == {
|
||||
"IMU_SAMPLE",
|
||||
"ATTITUDE",
|
||||
"GPS_HEALTH",
|
||||
"MAV_STATE",
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4: Factory rejects build-flag OFF
|
||||
|
||||
|
||||
def test_ac4_fc_factory_rejects_build_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "OFF")
|
||||
register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
|
||||
config = Config(fc=FcConfig(adapter="ardupilot_plane"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcAdapterConfigError, match=r"BUILD_FC_ARDUPILOT_PLANE is OFF"):
|
||||
build_fc_adapter(config)
|
||||
|
||||
|
||||
def test_ac4_fc_factory_passes_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
|
||||
register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
|
||||
config = Config(fc=FcConfig(adapter="ardupilot_plane"))
|
||||
|
||||
# Act
|
||||
adapter = build_fc_adapter(config)
|
||||
|
||||
# Assert
|
||||
assert isinstance(adapter, FcAdapter)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-5: Factory rejects unknown strategy at config-load
|
||||
|
||||
|
||||
def test_ac5_unknown_fc_strategy_rejected_at_config_load() -> None:
|
||||
# Act + Assert — happens at construction time, not at build time
|
||||
with pytest.raises(ConfigError, match=r"not in \['ardupilot_plane', 'inav'\]"):
|
||||
FcConfig(adapter="garbage_fc")
|
||||
|
||||
|
||||
def test_ac5_unknown_gcs_strategy_rejected_at_config_load() -> None:
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"not in \['qgc_mavlink'\]"):
|
||||
GcsConfig(adapter="garbage_gcs")
|
||||
|
||||
|
||||
def test_ac5_inav_signing_key_combination_rejected() -> None:
|
||||
# Act + Assert — iNav with signing key is RESTRICT-COMM-2 violation
|
||||
with pytest.raises(ConfigError, match=r"RESTRICT-COMM-2"):
|
||||
FcConfig(adapter="inav", signing_key_source="ephemeral_per_flight")
|
||||
|
||||
|
||||
def test_ac5_unregistered_strategy_rejected_at_build_with_clear_message() -> None:
|
||||
# Arrange — strategy is in the known set but the binary didn't register a factory
|
||||
config = Config(fc=FcConfig(adapter="inav", signing_key_source="none"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcAdapterConfigError, match=r"not registered"):
|
||||
build_fc_adapter(config)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: Single-writer outbound thread
|
||||
|
||||
|
||||
def test_ac6_first_bind_returns_thread_ident() -> None:
|
||||
# Act
|
||||
bound = bind_outbound_emit_thread()
|
||||
|
||||
# Assert
|
||||
assert bound == threading.get_ident()
|
||||
|
||||
|
||||
def test_ac6_second_bind_from_different_thread_rejected() -> None:
|
||||
# Arrange
|
||||
bind_outbound_emit_thread(thread_ident=1)
|
||||
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def attempt_rebind() -> None:
|
||||
try:
|
||||
bind_outbound_emit_thread(thread_ident=2)
|
||||
except OutboundThreadAlreadyBoundError as exc:
|
||||
errors.append(exc)
|
||||
|
||||
# Act
|
||||
t = threading.Thread(target=attempt_rebind)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
# Assert
|
||||
assert len(errors) == 1
|
||||
assert isinstance(errors[0], RuntimeError)
|
||||
|
||||
|
||||
def test_ac6_rebind_same_thread_idempotent() -> None:
|
||||
# Arrange
|
||||
first = bind_outbound_emit_thread(thread_ident=42)
|
||||
|
||||
# Act
|
||||
second = bind_outbound_emit_thread(thread_ident=42)
|
||||
|
||||
# Assert — re-binding the SAME thread is idempotent (composition root may run twice in tests)
|
||||
assert first == second == 42
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: GcsAdapter factory parallel coverage
|
||||
|
||||
|
||||
def test_ac7_gcs_factory_resolves_known_strategy(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "ON")
|
||||
register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
|
||||
config = Config(gcs=GcsConfig(adapter="qgc_mavlink"))
|
||||
|
||||
# Act
|
||||
adapter = build_gcs_adapter(config)
|
||||
|
||||
# Assert
|
||||
assert isinstance(adapter, GcsAdapter)
|
||||
|
||||
|
||||
def test_ac7_gcs_factory_rejects_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "OFF")
|
||||
register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
|
||||
config = Config(gcs=GcsConfig(adapter="qgc_mavlink"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(GcsAdapterConfigError, match=r"BUILD_GCS_QGC_MAVLINK is OFF"):
|
||||
build_gcs_adapter(config)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-9: Error hierarchy catchability
|
||||
|
||||
|
||||
def test_ac9_every_fc_error_is_fc_adapter_error() -> None:
|
||||
# Assert — all FC errors share the base
|
||||
for err_cls in [
|
||||
FcOpenError,
|
||||
FcEmitError,
|
||||
SigningHandshakeError,
|
||||
SourceSetSwitchError,
|
||||
SourceSetSwitchNotSupportedError,
|
||||
FcAdapterConfigError,
|
||||
]:
|
||||
assert issubclass(err_cls, FcAdapterError), err_cls
|
||||
|
||||
|
||||
def test_ac9_source_set_switch_not_supported_is_subclass_of_switch_error() -> None:
|
||||
# Assert
|
||||
assert issubclass(SourceSetSwitchNotSupportedError, SourceSetSwitchError)
|
||||
|
||||
|
||||
def test_ac9_gcs_errors_share_base() -> None:
|
||||
# Assert
|
||||
for err_cls in [GcsEmitError, GcsAdapterConfigError]:
|
||||
assert issubclass(err_cls, GcsAdapterError), err_cls
|
||||
|
||||
|
||||
def test_ac9_fc_and_gcs_trees_are_disjoint() -> None:
|
||||
# Assert — catching FcAdapterError must NOT catch GcsAdapterError
|
||||
assert not issubclass(GcsAdapterError, FcAdapterError)
|
||||
assert not issubclass(FcAdapterError, GcsAdapterError)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-10: INFO log on build
|
||||
|
||||
|
||||
def test_ac10_info_log_on_fc_build(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
|
||||
register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
|
||||
config = Config(fc=FcConfig(adapter="ardupilot_plane", port_device="/dev/ttyS0"))
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger="runtime_root.fc_factory"):
|
||||
build_fc_adapter(config)
|
||||
|
||||
# Assert — exactly one strategy_loaded record
|
||||
matches = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c8.adapter.strategy_loaded"
|
||||
]
|
||||
assert len(matches) == 1
|
||||
assert matches[0].kv["strategy"] == "ardupilot_plane"
|
||||
assert matches[0].kv["port_device"] == "/dev/ttyS0"
|
||||
|
||||
|
||||
def test_ac10_info_log_on_gcs_build(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
# Arrange
|
||||
monkeypatch.setenv("BUILD_GCS_QGC_MAVLINK", "ON")
|
||||
register_gcs_adapter("qgc_mavlink", lambda **_: _GcsStub())
|
||||
config = Config(gcs=GcsConfig(adapter="qgc_mavlink", port_device="/dev/ttyS1"))
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.INFO, logger="runtime_root.fc_factory"):
|
||||
build_gcs_adapter(config)
|
||||
|
||||
# Assert
|
||||
matches = [r for r in caplog.records if getattr(r, "kind", None) == "c8.gcs.strategy_loaded"]
|
||||
assert len(matches) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR: build perf (loose budget — sanity check, not microbench)
|
||||
|
||||
|
||||
def test_nfr_perf_fc_build_under_50ms(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
import time
|
||||
|
||||
monkeypatch.setenv("BUILD_FC_ARDUPILOT_PLANE", "ON")
|
||||
register_fc_adapter("ardupilot_plane", lambda **_: _FcStub())
|
||||
config = Config(fc=FcConfig(adapter="ardupilot_plane"))
|
||||
|
||||
# Act
|
||||
start = time.monotonic()
|
||||
build_fc_adapter(config)
|
||||
elapsed_s = time.monotonic() - start
|
||||
|
||||
# Assert
|
||||
assert elapsed_s < 0.05, f"build took {elapsed_s * 1000:.2f}ms (budget 50ms)"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Coverage of the FcConfig.signing_key_source validator
|
||||
|
||||
|
||||
def test_signing_key_source_unknown_value_rejected() -> None:
|
||||
# Act + Assert
|
||||
with pytest.raises(ConfigError, match=r"signing_key_source"):
|
||||
FcConfig(adapter="ardupilot_plane", signing_key_source="garbage")
|
||||
|
||||
|
||||
def test_gcs_summary_rate_out_of_range_rejected() -> None:
|
||||
# Act + Assert — too high
|
||||
with pytest.raises(ConfigError, match=r"summary_rate_hz"):
|
||||
GcsConfig(summary_rate_hz=5.0)
|
||||
# Too low
|
||||
with pytest.raises(ConfigError, match=r"summary_rate_hz"):
|
||||
GcsConfig(summary_rate_hz=0.5)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""AZ-392 — CovarianceProjector unit tests.
|
||||
|
||||
Covers all 7 ACs:
|
||||
1. 6x6 -> 3x3 -> 2x2 projection correctness
|
||||
2. AP returns meters (float)
|
||||
3. iNav returns mm (uint16-clamped)
|
||||
4. SPD violation raises FcEmitError + emits FDR ERROR
|
||||
5. NaN guard
|
||||
6. Bit-stable across calls (deterministic intermediate arithmetic)
|
||||
7. iNav clamp at 65535 emits WARN log
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.pose import EstimatorHealth, EstimatorOutput
|
||||
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
||||
CovarianceProjector,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import FcEmitError
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord
|
||||
|
||||
|
||||
def _output(cov: np.ndarray | None, frame_id: int = 7) -> EstimatorOutput:
|
||||
return EstimatorOutput(
|
||||
frame_id=frame_id,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
pose_se3=np.eye(4),
|
||||
covariance_6x6=cov,
|
||||
source_label="visual_propagated",
|
||||
health=EstimatorHealth(),
|
||||
)
|
||||
|
||||
|
||||
def _spd_6x6(sigma_xx: float = 4.0, sigma_yy: float = 9.0, sigma_xy: float = 0.0) -> np.ndarray:
|
||||
"""Construct a 6x6 covariance whose 2x2 horizontal block has known eigenvalues."""
|
||||
cov = np.eye(6)
|
||||
cov[0, 0] = sigma_xx
|
||||
cov[1, 1] = sigma_yy
|
||||
cov[0, 1] = sigma_xy
|
||||
cov[1, 0] = sigma_xy
|
||||
return cov
|
||||
|
||||
|
||||
def _fake_fdr_client() -> mock.MagicMock:
|
||||
client = mock.MagicMock()
|
||||
client.enqueue.return_value = "OK"
|
||||
return client
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-1: 6x6 -> 3x3 -> 2x2 reduction correctness
|
||||
|
||||
|
||||
def test_ac1_diagonal_covariance_returns_largest_sigma() -> None:
|
||||
# Arrange — sigma_yy > sigma_xx, so lambda_max == 9.0 and radius == 3.0
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=4.0, sigma_yy=9.0))
|
||||
|
||||
# Act
|
||||
radius_m = proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
# Assert
|
||||
assert math.isclose(radius_m, 3.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_ac1_with_off_diagonal_uses_eigenvalue() -> None:
|
||||
# Arrange — [[4, 1], [1, 4]] has eigenvalues 5 and 3; lambda_max = 5
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=4.0, sigma_yy=4.0, sigma_xy=1.0))
|
||||
|
||||
# Act
|
||||
radius_m = proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
# Assert
|
||||
assert math.isclose(radius_m, math.sqrt(5.0), rel_tol=1e-12)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-2: AP returns meters (float)
|
||||
|
||||
|
||||
def test_ac2_ap_returns_float_meters() -> None:
|
||||
# Arrange
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=1.0, sigma_yy=1.0))
|
||||
|
||||
# Act
|
||||
result = proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, float)
|
||||
assert math.isclose(result, 1.0, abs_tol=1e-12)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-3: iNav returns mm (int)
|
||||
|
||||
|
||||
def test_ac3_inav_returns_int_millimeters() -> None:
|
||||
# Arrange — sigma == 1 m^2 -> radius = 1 m = 1000 mm
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=1.0, sigma_yy=1.0))
|
||||
|
||||
# Act
|
||||
result = proj.to_inav_h_pos_accuracy_mm(out)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, int)
|
||||
assert result == 1000
|
||||
|
||||
|
||||
def test_ac3_inav_rounds_half_up() -> None:
|
||||
# Arrange — radius = sqrt(0.5) ~ 0.7071067... m -> 707.1067 mm -> round-half-up -> 707
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=0.5, sigma_yy=0.5))
|
||||
|
||||
# Act
|
||||
result = proj.to_inav_h_pos_accuracy_mm(out)
|
||||
|
||||
# Assert
|
||||
assert result == 707
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-4 + AC-5: SPD / NaN violations
|
||||
|
||||
|
||||
def test_ac4_non_spd_raises_fc_emit_error_and_logs_fdr() -> None:
|
||||
# Arrange — negative determinant 2x2 block
|
||||
fdr = _fake_fdr_client()
|
||||
proj = CovarianceProjector(fdr_client=fdr)
|
||||
cov = _spd_6x6()
|
||||
cov[0, 0] = -1.0 # break positive-definiteness
|
||||
out = _output(cov)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError, match=r"non-SPD"):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
# Assert — exactly one FDR ERROR record with the violation kv
|
||||
assert fdr.enqueue.call_count == 1
|
||||
record: FdrRecord = fdr.enqueue.call_args.args[0]
|
||||
assert record.kind == "log"
|
||||
assert record.payload["kv"]["reason"] == "non_spd"
|
||||
assert record.payload["kv"]["frame_id"] == 7
|
||||
|
||||
|
||||
def test_ac4_asymmetric_2x2_rejected() -> None:
|
||||
# Arrange
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
cov = np.eye(6)
|
||||
cov[0, 1] = 1.0
|
||||
cov[1, 0] = 2.0 # ≠ cov[0, 1] → asymmetric
|
||||
out = _output(cov)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError, match=r"non-SPD"):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
|
||||
def test_ac5_nan_covariance_rejected() -> None:
|
||||
# Arrange
|
||||
fdr = _fake_fdr_client()
|
||||
proj = CovarianceProjector(fdr_client=fdr)
|
||||
cov = _spd_6x6()
|
||||
cov[2, 2] = math.nan
|
||||
out = _output(cov)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError, match=r"NaN"):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
assert fdr.enqueue.call_args.args[0].payload["kv"]["reason"] == "nan_or_inf"
|
||||
|
||||
|
||||
def test_ac5_inf_covariance_rejected() -> None:
|
||||
# Arrange
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
cov = _spd_6x6()
|
||||
cov[2, 2] = math.inf
|
||||
out = _output(cov)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
|
||||
|
||||
def test_ac5_missing_covariance_rejected() -> None:
|
||||
# Arrange
|
||||
fdr = _fake_fdr_client()
|
||||
proj = CovarianceProjector(fdr_client=fdr)
|
||||
out = _output(cov=None)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError, match=r"missing"):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
assert fdr.enqueue.call_args.args[0].payload["kv"]["reason"] == "missing"
|
||||
|
||||
|
||||
def test_ac5_wrong_shape_rejected() -> None:
|
||||
# Arrange
|
||||
fdr = _fake_fdr_client()
|
||||
proj = CovarianceProjector(fdr_client=fdr)
|
||||
out = _output(cov=np.eye(5))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(FcEmitError, match=r"6x6"):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
assert fdr.enqueue.call_args.args[0].payload["kv"]["reason"] == "bad_shape"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-6: bit-stable across calls
|
||||
|
||||
|
||||
def test_ac6_bit_stable_repeated_calls() -> None:
|
||||
# Arrange — repeated calls on identical input yield bit-identical output
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=3.7, sigma_yy=2.1, sigma_xy=0.4))
|
||||
|
||||
# Act
|
||||
results = [proj.to_ardupilot_horiz_accuracy_m(out) for _ in range(20)]
|
||||
|
||||
# Assert — every call produces exactly the same float
|
||||
assert len(set(results)) == 1
|
||||
|
||||
|
||||
def test_ac6_ap_and_inav_round_trip_consistent() -> None:
|
||||
# Arrange — AP m * 1000 (round-half-up) must equal iNav mm
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=2.0, sigma_yy=2.0))
|
||||
|
||||
# Act
|
||||
radius_m = proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
radius_mm = proj.to_inav_h_pos_accuracy_mm(out)
|
||||
|
||||
# Assert
|
||||
assert radius_mm == math.floor(radius_m * 1000.0 + 0.5)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# AC-7: iNav clamp at 65535
|
||||
|
||||
|
||||
def test_ac7_inav_clamps_at_uint16_max(caplog: pytest.LogCaptureFixture) -> None:
|
||||
# Arrange — sigma huge enough that radius_mm > 65535
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
huge_sigma = 1e8 # radius ~ sqrt(1e8) m = 10000 m = 10_000_000 mm
|
||||
out = _output(_spd_6x6(sigma_xx=huge_sigma, sigma_yy=huge_sigma))
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING, logger="c8_fc_adapter.cov_projector"):
|
||||
result = proj.to_inav_h_pos_accuracy_mm(out)
|
||||
|
||||
# Assert
|
||||
assert result == 65535
|
||||
clamp_records = [
|
||||
r for r in caplog.records if getattr(r, "kind", None) == "c8.cov_projector.inav_clamped"
|
||||
]
|
||||
assert len(clamp_records) == 1
|
||||
assert clamp_records[0].kv["clamped_to"] == 65535
|
||||
assert clamp_records[0].kv["frame_id"] == 7
|
||||
|
||||
|
||||
def test_ac7_inav_exact_at_uint16_max_not_clamped() -> None:
|
||||
# Arrange — pick sigma_xx so that radius_mm == 65535 exactly
|
||||
# radius_m = 65.535, sigma = 65.535^2 = 4294.838...
|
||||
sigma = (65.535) ** 2
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=sigma, sigma_yy=sigma))
|
||||
|
||||
# Act
|
||||
result = proj.to_inav_h_pos_accuracy_mm(out)
|
||||
|
||||
# Assert — at the boundary, no clamp
|
||||
assert result == 65535
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# NFR: perf (loose budget — projector must be fast enough for 20 Hz emit)
|
||||
|
||||
|
||||
def test_nfr_perf_projector_under_100us_per_call() -> None:
|
||||
# Arrange
|
||||
import time
|
||||
|
||||
proj = CovarianceProjector(fdr_client=_fake_fdr_client())
|
||||
out = _output(_spd_6x6(sigma_xx=2.0, sigma_yy=3.0, sigma_xy=0.5))
|
||||
|
||||
iters = 1000
|
||||
|
||||
# Act
|
||||
start = time.perf_counter()
|
||||
for _ in range(iters):
|
||||
proj.to_ardupilot_horiz_accuracy_m(out)
|
||||
avg_s = (time.perf_counter() - start) / iters
|
||||
|
||||
# Assert
|
||||
assert avg_s < 100e-6, f"avg {avg_s * 1e6:.1f}us > 100us budget"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""C8 FC Adapter smoke test — AC-9."""
|
||||
"""C8 FC Adapter smoke test — AC-9 (legacy) + AZ-390 public-API gate."""
|
||||
|
||||
|
||||
def test_interface_importable() -> None:
|
||||
@@ -12,3 +12,17 @@ def test_interface_importable() -> None:
|
||||
|
||||
for sym in (FcAdapter, GcsAdapter, ReplaySink, EmittedExternalPosition):
|
||||
assert sym is not None
|
||||
|
||||
|
||||
def test_internal_modules_not_in_public_all() -> None:
|
||||
"""AZ-390 AC-8: only the contract symbols appear in ``__all__``."""
|
||||
# Arrange
|
||||
from gps_denied_onboard.components import c8_fc_adapter
|
||||
|
||||
# Assert
|
||||
assert set(c8_fc_adapter.__all__) == {
|
||||
"EmittedExternalPosition",
|
||||
"FcAdapter",
|
||||
"GcsAdapter",
|
||||
"ReplaySink",
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ REQUIRED_PATHS: tuple[str, ...] = (
|
||||
"ci/sbom_diff.py",
|
||||
"ci/opencv_pin_gate.py",
|
||||
"src/gps_denied_onboard/__init__.py",
|
||||
"src/gps_denied_onboard/runtime_root.py",
|
||||
"src/gps_denied_onboard/runtime_root/__init__.py",
|
||||
"src/gps_denied_onboard/healthcheck.py",
|
||||
"src/gps_denied_onboard/_types/__init__.py",
|
||||
"src/gps_denied_onboard/helpers/__init__.py",
|
||||
|
||||
Reference in New Issue
Block a user