mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 00:31: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,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, ...]:
|
||||
|
||||
Reference in New Issue
Block a user