mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 20:21:17 +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>
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""AZ-397 — QgcTelemetryAdapter AC tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest import mock
|
|
from uuid import UUID, uuid4
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.fc import (
|
|
FcKind,
|
|
OperatorCommand,
|
|
PortConfig,
|
|
Severity,
|
|
)
|
|
from gps_denied_onboard._types.geo import LatLonAlt
|
|
from gps_denied_onboard._types.state import (
|
|
EstimatorOutput,
|
|
PoseSourceLabel,
|
|
Quat,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter._covariance_projector import (
|
|
CovarianceProjector,
|
|
)
|
|
from gps_denied_onboard.components.c8_fc_adapter.errors import GcsAdapterConfigError
|
|
from gps_denied_onboard.components.c8_fc_adapter.mavlink_gcs_adapter import (
|
|
QgcTelemetryAdapter,
|
|
_compute_downsample_modulo,
|
|
)
|
|
from gps_denied_onboard.config import load_config
|
|
from gps_denied_onboard.config.schema import ConfigError, GcsConfig
|
|
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Helpers
|
|
|
|
|
|
class _MavStub:
|
|
def __init__(self) -> None:
|
|
self.global_position_int_calls: list[tuple[Any, ...]] = []
|
|
self.named_value_float_calls: list[tuple[Any, ...]] = []
|
|
self.statustext_calls: list[tuple[int, bytes]] = []
|
|
|
|
def global_position_int_send(self, *args: Any) -> None:
|
|
self.global_position_int_calls.append(args)
|
|
|
|
def named_value_float_send(self, time_boot_ms: int, name: bytes, value: float) -> None:
|
|
self.named_value_float_calls.append((time_boot_ms, name, value))
|
|
|
|
def statustext_send(self, severity: int, text: bytes) -> None:
|
|
self.statustext_calls.append((severity, text))
|
|
|
|
|
|
class _ConnStub:
|
|
def __init__(self) -> None:
|
|
self.mav = _MavStub()
|
|
self.message_hooks: list[Any] = []
|
|
self.closed = False
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _config(*, summary_rate_hz: float = 2.0) -> Any:
|
|
env = {
|
|
"FC_ADAPTER": "ardupilot_plane",
|
|
"FC_PORT_DEVICE": "/dev/null",
|
|
"FC_PORT_BAUD": "921600",
|
|
"FC_SIGNING_KEY_SOURCE": "none",
|
|
"GCS_ADAPTER": "qgc_mavlink",
|
|
"GCS_PORT_DEVICE": "/dev/null",
|
|
"GCS_PORT_BAUD": "921600",
|
|
"GCS_SUMMARY_RATE_HZ": str(summary_rate_hz),
|
|
}
|
|
return load_config(env=env, paths=(), require_env=False)
|
|
|
|
|
|
def _make_output(
|
|
*,
|
|
source_label: PoseSourceLabel = PoseSourceLabel.VISUAL_PROPAGATED,
|
|
frame_id: UUID | int | None = None,
|
|
) -> EstimatorOutput:
|
|
if frame_id is None:
|
|
frame_id = uuid4()
|
|
elif isinstance(frame_id, int):
|
|
frame_id = UUID(int=frame_id)
|
|
return EstimatorOutput(
|
|
frame_id=frame_id,
|
|
position_wgs84=LatLonAlt(lat_deg=50.0, lon_deg=30.0, alt_m=100.0),
|
|
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
|
velocity_world_mps=(0.0, 0.0, 0.0),
|
|
covariance_6x6=np.eye(6, dtype=np.float64) * 0.25,
|
|
source_label=source_label,
|
|
last_satellite_anchor_age_ms=0,
|
|
smoothed=False,
|
|
emitted_at=0,
|
|
)
|
|
|
|
|
|
def _make_adapter(conn: _ConnStub, cfg: Any) -> QgcTelemetryAdapter:
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=cfg,
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
return a
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: 5 -> 2 Hz downsampling
|
|
|
|
|
|
def test_ac1_5hz_to_2hz_downsample() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=2.0))
|
|
try:
|
|
for i in range(100):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
# modulo = round(5/2) = 2 (round half to even gives 2; we just
|
|
# need parity with the documented choice). 100 / 2 = 50.
|
|
assert _compute_downsample_modulo(2.0) == 2
|
|
assert len(conn.mav.global_position_int_calls) == 50
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: configurable rate
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"rate_hz,expected_frames",
|
|
[
|
|
(1.0, 20), # modulo 5 -> 100/5
|
|
(5.0, 100), # modulo 1 -> no downsample
|
|
],
|
|
)
|
|
def test_ac2_configurable_rate(rate_hz: float, expected_frames: int) -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=rate_hz))
|
|
try:
|
|
for i in range(100):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
assert len(conn.mav.global_position_int_calls) == expected_frames
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
def test_ac2_out_of_range_rate_rejected_at_config() -> None:
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=10.0)
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=0.2)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: summary frame fields
|
|
|
|
|
|
def test_ac3_summary_frame_fields() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(summary_rate_hz=5.0), # no downsample for simplicity
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
try:
|
|
wgs = LatLonAlt(lat_deg=50.45, lon_deg=30.52, alt_m=180.0)
|
|
output = EstimatorOutput(
|
|
frame_id=UUID(int=1),
|
|
position_wgs84=wgs,
|
|
orientation_world_T_body=Quat(w=1.0, x=0.0, y=0.0, z=0.0),
|
|
velocity_world_mps=(0.0, 0.0, 0.0),
|
|
covariance_6x6=np.diag([0.25, 0.25, 9.0, 1.0, 1.0, 1.0]).astype(np.float64),
|
|
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
|
|
last_satellite_anchor_age_ms=0,
|
|
smoothed=False,
|
|
emitted_at=0,
|
|
)
|
|
a.emit_summary(output)
|
|
assert len(conn.mav.global_position_int_calls) == 1
|
|
call = conn.mav.global_position_int_calls[0]
|
|
# global_position_int_send(time_boot_ms, lat, lon, alt_mm, rel_alt_mm, vx, vy, vz, hdg)
|
|
assert call[1] == int(wgs.lat_deg * 1e7)
|
|
assert call[2] == int(wgs.lon_deg * 1e7)
|
|
assert call[3] == int(wgs.alt_m * 1000.0)
|
|
# horiz_accuracy via NAMED_VALUE_FLOAT
|
|
assert len(conn.mav.named_value_float_calls) == 1
|
|
_, name, value = conn.mav.named_value_float_calls[0]
|
|
assert name == b"horiz_m"
|
|
expected = CovarianceProjector(fdr_client=mock.MagicMock()).to_ardupilot_horiz_accuracy_m(
|
|
output
|
|
)
|
|
assert value == pytest.approx(expected)
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: STATUSTEXT mirror
|
|
|
|
|
|
def test_ac4_statustext_mirror() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=2.0))
|
|
try:
|
|
a.emit_status_text("hello", Severity.INFO)
|
|
assert len(conn.mav.statustext_calls) == 1
|
|
sev, text = conn.mav.statustext_calls[0]
|
|
assert sev == int(Severity.INFO.value)
|
|
assert text == b"hello"
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: operator command subscription
|
|
|
|
|
|
def test_ac5_operator_command_subscription_invokes_callback() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config())
|
|
received: list[OperatorCommand] = []
|
|
try:
|
|
a.subscribe_operator_commands(received.append)
|
|
# Inject a PARAM_REQUEST_LIST stub
|
|
msg = SimpleNamespace(target_system=1, target_component=1)
|
|
msg.get_type = lambda: "PARAM_REQUEST_LIST"
|
|
msg.get_srcSystem = lambda: 42
|
|
# Fire every registered hook
|
|
for hook in conn.message_hooks:
|
|
hook(conn, msg)
|
|
assert len(received) == 1
|
|
cmd = received[0]
|
|
assert cmd.command == "PARAM_REQUEST_LIST"
|
|
assert cmd.payload.get("target_system") == 1
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: operator command FDR audit trail
|
|
|
|
|
|
def test_ac6_operator_command_fdr_audit_trail() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(),
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.GCS_QGC, device="/dev/null", baud=921600)
|
|
a.open(port)
|
|
try:
|
|
a.subscribe_operator_commands(lambda _cmd: None)
|
|
msg = SimpleNamespace(target_system=1, target_component=1, command=400)
|
|
msg.get_type = lambda: "COMMAND_LONG"
|
|
msg.get_srcSystem = lambda: 7
|
|
for hook in conn.message_hooks:
|
|
hook(conn, msg)
|
|
operator_records = [
|
|
call
|
|
for call in fdr.enqueue.mock_calls
|
|
if call.args[0].payload.get("kind") == "c8.gcs.operator_command"
|
|
]
|
|
assert len(operator_records) == 1
|
|
rec = operator_records[0].args[0].payload
|
|
assert rec["kv"]["command"] == "COMMAND_LONG"
|
|
assert rec["kv"]["source_system"] == 7
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: single-writer thread for outbound
|
|
|
|
|
|
def test_ac7_single_writer_thread() -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=5.0))
|
|
err: list[BaseException] = []
|
|
try:
|
|
a.emit_summary(_make_output())
|
|
|
|
def run() -> None:
|
|
try:
|
|
a.emit_summary(_make_output(frame_id=2))
|
|
except RuntimeError as e:
|
|
err.append(e)
|
|
|
|
t = threading.Thread(target=run)
|
|
t.start()
|
|
t.join(timeout=2.0)
|
|
assert len(err) == 1
|
|
assert "single-writer" in str(err[0]).lower()
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: first emit logged once
|
|
|
|
|
|
def test_ac8_first_emit_logged_once(caplog: pytest.LogCaptureFixture) -> None:
|
|
conn = _ConnStub()
|
|
a = _make_adapter(conn, _config(summary_rate_hz=5.0))
|
|
try:
|
|
with caplog.at_level(logging.INFO):
|
|
for i in range(5):
|
|
a.emit_summary(_make_output(frame_id=i))
|
|
first = [
|
|
r for r in caplog.records if getattr(r, "kind", None) == "c8.gcs.first_summary_emit"
|
|
]
|
|
assert len(first) == 1
|
|
finally:
|
|
a.close()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: WGS84 round-trip (defensive)
|
|
|
|
|
|
def test_ac9_wgs84_round_trip_within_1cm() -> None:
|
|
# Round-trip a known lat/lon through WgsConverter ECEF and back.
|
|
origin = LatLonAlt(lat_deg=50.45, lon_deg=30.52, alt_m=180.0)
|
|
enu = WgsConverter.latlonalt_to_local_enu(
|
|
origin, LatLonAlt(lat_deg=50.4501, lon_deg=30.5201, alt_m=180.5)
|
|
)
|
|
recovered = WgsConverter.local_enu_to_latlonalt(origin, enu)
|
|
expected = LatLonAlt(lat_deg=50.4501, lon_deg=30.5201, alt_m=180.5)
|
|
# 1 cm threshold — pyproj's ENU round-trip is sub-millimetre at
|
|
# these distances; we keep the AC's 1 cm threshold as the contract.
|
|
enu_residual = WgsConverter.latlonalt_to_local_enu(expected, recovered)
|
|
assert np.linalg.norm(enu_residual) < 0.01
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-10: GcsAdapterConfigError on bad config (config-load path)
|
|
|
|
|
|
def test_ac10_gcs_config_error_on_bad_rate() -> None:
|
|
with pytest.raises(ConfigError, match="summary_rate_hz"):
|
|
GcsConfig(summary_rate_hz=6.0)
|
|
|
|
|
|
def test_ac10_open_rejects_wrong_fc_kind() -> None:
|
|
conn = _ConnStub()
|
|
fdr = mock.MagicMock()
|
|
a = QgcTelemetryAdapter(
|
|
config=_config(),
|
|
wgs_converter=mock.MagicMock(),
|
|
covariance_projector=CovarianceProjector(fdr_client=fdr),
|
|
fdr_client=fdr,
|
|
connect_factory=lambda device, baud: conn,
|
|
)
|
|
port = PortConfig(fc_kind=FcKind.ARDUPILOT_PLANE, device="/dev/null", baud=921600)
|
|
with pytest.raises(GcsAdapterConfigError, match="GCS_QGC"):
|
|
a.open(port)
|