[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
@@ -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, ...]: