mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 22:21:13 +00:00
beed43724f
- Add StateEstimator Protocol (6 methods, @runtime_checkable) + DTOs (EstimatorOutput, EstimatorHealth, IsamState, PoseSourceLabel, Quat) in _types/state.py per state_estimator_protocol.md v1.0.0. - Add C5 error hierarchy (StateEstimatorError + 3 subclasses) and C5StateConfig (strategy, keyframe_window, spoof gates, no_estimate_fallback_s) with __post_init__ validation. - Add ISam2GraphHandle Protocol + ISam2GraphHandleImpl skeleton (all 4 methods raise NotImplementedError naming AZ-382 as owner). - Add build_state_estimator factory + bind_state_ingest_thread for single-writer enforcement; ADR-002 build-flag gating (BUILD_STATE_<variant>); INFO log on success. - Strict reshape of legacy EstimatorOutput / EstimatorHealth across all 6 C8 production files (_outbound_provenance, _covariance_projector, pymavlink_ardupilot_adapter, msp2_inav_adapter, mavlink_gcs_adapter, interface) + 6 C8 test files (UUID frame_id, LatLonAlt position_wgs84, Quat orientation, PoseSourceLabel enum source_label). Remove ad-hoc DTOs from _types/pose.py and from C4's public __init__ (EstimatorOutput is a C5 concept, not a C4 one). - 20 AZ-381 AC tests (10 ACs + 4 config range + NFR + conformance). - Full suite: 521 passed, 2 skipped (+20 vs Batch 11). - Contracts: state_estimator_protocol.md v1.0.0 -> active; composition_root_protocol.md v1.2.0 -> v1.3.0 (additive state block + factory + ingest-thread binding). - Impl report: _docs/03_implementation/batch_12_cycle1_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
516 lines
15 KiB
Python
516 lines
15 KiB
Python
"""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.state 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", "GCS_QGC"}
|
|
|
|
|
|
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:
|
|
# AZ-397 widened the valid range to [0.5, 5.0] (AC-10); the boundary
|
|
# cases below now fall OUTSIDE the new range.
|
|
# Act + Assert — too high
|
|
with pytest.raises(ConfigError, match=r"summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=5.1)
|
|
# Too low
|
|
with pytest.raises(ConfigError, match=r"summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=0.4)
|