mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 16:51:13 +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:
@@ -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)
|
||||
Reference in New Issue
Block a user