[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
@@ -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},
},
)