[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:
Oleksandr Bezdieniezhnykh
2026-05-11 04:17:59 +03:00
parent e4ecdaf619
commit 362e93c626
22 changed files with 1909 additions and 59 deletions
+26 -10
View File
@@ -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
+177
View File
@@ -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]
+5 -17
View File
@@ -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",
+27
View File
@@ -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,
)
+86 -4
View File
@@ -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, ...]:
@@ -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},
},
)