mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 16:31:14 +00:00
6c7d24f7e0
Freezes the c1_vio Public API per _docs/02_document/contracts/c1_vio/vio_strategy_protocol.md v1.0.0: - VioStrategy Protocol (4 methods: process_frame, reset_to_warm_start, health_snapshot, current_strategy_label) in components/c1_vio/interface.py. - DTOs (VioOutput, VioHealth, FeatureQuality, WarmStartPose) + VioState enum in _types/nav.py — L1 placement so C5 + C13 consume them without crossing the components.* boundary (AZ-270 AC-6). The new VioOutput shape (frame_id: str, relative_pose_T: gtsam.Pose3, pose_covariance_6x6, imu_bias, feature_quality, emitted_at_ns) replaces the AZ-263 scaffolding in _types/vio.py, which is now deleted. - VioError family (VioInitializingError / VioDegradedError / VioFatalError) in components/c1_vio/errors.py. Documented rationale: the degraded-operation path returns a VioOutput with inflated covariance + VioHealth.state=DEGRADED rather than raising VioDegradedError — the error type exists only for the rare degraded->fatal transition. - C1VioConfig per-component config block (strategy enum, lost_frame_threshold default 9, warm_start_max_frames default 5) with constructor-time validation rejecting unknown strategy labels. - StrategyNotAvailableError added to runtime_root/errors.py; composition-time error distinct from the VioError family. - Composition-root factory build_vio_strategy in runtime_root/vio_factory.py with three BUILD_* gates (BUILD_OKVIS2, BUILD_VINS_MONO, BUILD_KLT_RANSAC). Concrete strategy modules are imported lazily via __import__ AFTER the flag check — Tier-0 workstation builds with the flag OFF MUST NOT load the strategy module (Risk-2 / I-5; verifiable via sys.modules). - 36 conformance tests cover all 9 ACs + NFR-perf-factory (p99 build under 200 ms x 1000 calls) + NFR-reliability-error-family. AC-8 introspects the contract file's Shape table and asserts method parity against the runtime Protocol; AC-9 asserts the frame_id annotation is 'str' (PEP-563 stringified). C5 migration (consumers of the new VioOutput shape): - gtsam_isam2_estimator.py + eskf_baseline.py: replaced vio.timestamp -> vio.emitted_at_ns (drops _datetime_to_ns on the VIO path), vio.pose_se3 -> vio.relative_pose_T (gtsam.Pose3 direct; drops _pose_se3_to_gtsam / _pose_se3_to_array), vio.covariance_6x6 -> vio.pose_covariance_6x6 (rename). - key_for_frame signature widened to UUID | int | str to accept the new str frame_id. - 4 C5 test files migrated to the new VioOutput shape with helper fixtures producing ImuBias + FeatureQuality + str frame_id. - c5_state/interface.py TYPE_CHECKING import path updated. Bootstrap healthcheck + test_types_importable updated to drop the deleted _types/vio module and pick up _types/inference (AZ-297) in the same sweep. Full unit-test sweep: 884 passed, 2 pre-existing environment skips (cmake, actionlint). Co-authored-by: Cursor <cursoragent@cursor.com>
426 lines
13 KiB
Python
426 lines
13 KiB
Python
"""AZ-381 — StateEstimator Protocol + DTOs + factory + concrete handle.
|
|
|
|
Covers all 10 ACs of AZ-381 (see ``_docs/02_tasks/done/AZ-381...``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import logging
|
|
import threading
|
|
from typing import Any
|
|
from unittest import mock
|
|
from uuid import uuid4
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.geo import LatLonAlt
|
|
from gps_denied_onboard._types.pose import PoseEstimate
|
|
from gps_denied_onboard._types.state import (
|
|
EstimatorHealth,
|
|
EstimatorOutput,
|
|
IsamState,
|
|
PoseSourceLabel,
|
|
Quat,
|
|
)
|
|
from gps_denied_onboard._types.nav import VioOutput
|
|
from gps_denied_onboard.components.c5_state import (
|
|
C5StateConfig,
|
|
EstimatorDegradedError,
|
|
EstimatorFatalError,
|
|
StateEstimator,
|
|
StateEstimatorConfigError,
|
|
StateEstimatorError,
|
|
)
|
|
from gps_denied_onboard.components.c5_state._isam2_handle import (
|
|
ISam2GraphHandle,
|
|
ISam2GraphHandleImpl,
|
|
)
|
|
from gps_denied_onboard.config import load_config
|
|
from gps_denied_onboard.config.schema import Config, ConfigError
|
|
from gps_denied_onboard.runtime_root.state_factory import (
|
|
StateIngestThreadAlreadyBoundError,
|
|
bind_state_ingest_thread,
|
|
build_state_estimator,
|
|
clear_state_ingest_binding,
|
|
clear_state_registry,
|
|
register_state_estimator,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolation():
|
|
clear_state_registry()
|
|
clear_state_ingest_binding()
|
|
yield
|
|
clear_state_registry()
|
|
clear_state_ingest_binding()
|
|
|
|
|
|
def _make_estimator_output() -> EstimatorOutput:
|
|
return EstimatorOutput(
|
|
frame_id=uuid4(),
|
|
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),
|
|
source_label=PoseSourceLabel.VISUAL_PROPAGATED,
|
|
last_satellite_anchor_age_ms=0,
|
|
smoothed=False,
|
|
emitted_at=0,
|
|
)
|
|
|
|
|
|
def _make_estimator_health() -> EstimatorHealth:
|
|
return EstimatorHealth(
|
|
isam2_state=IsamState.TRACKING,
|
|
keyframe_count=15,
|
|
cov_norm_growing_for_s=0.0,
|
|
spoof_promotion_blocked=False,
|
|
)
|
|
|
|
|
|
def _build_config(**state_overrides: Any) -> Config:
|
|
cfg = load_config(env={}, paths=(), require_env=False)
|
|
# Replace c5_state block with overrides.
|
|
new_state = dataclasses.replace(C5StateConfig(), **state_overrides)
|
|
components = dict(cfg.components or {})
|
|
components["c5_state"] = new_state
|
|
return dataclasses.replace(cfg, components=components)
|
|
|
|
|
|
class _FakeEstimator:
|
|
"""Test fake satisfying every StateEstimator method (AC-1)."""
|
|
|
|
def set_takeoff_origin(
|
|
self,
|
|
origin: LatLonAlt,
|
|
sigma_horiz_m: float,
|
|
sigma_vert_m: float,
|
|
) -> None:
|
|
pass
|
|
|
|
def add_vio(self, vio: VioOutput) -> None:
|
|
pass
|
|
|
|
def add_pose_anchor(self, pose: PoseEstimate) -> None:
|
|
pass
|
|
|
|
def add_fc_imu(self, imu_window: Any) -> None:
|
|
pass
|
|
|
|
def current_estimate(self) -> EstimatorOutput:
|
|
return _make_estimator_output()
|
|
|
|
def smoothed_history(self, n_keyframes: int) -> list[EstimatorOutput]:
|
|
return [_make_estimator_output() for _ in range(min(n_keyframes, 5))]
|
|
|
|
def health_snapshot(self) -> EstimatorHealth:
|
|
return _make_estimator_health()
|
|
|
|
|
|
def _fake_handle(estimator: Any) -> ISam2GraphHandle:
|
|
return ISam2GraphHandleImpl(estimator)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: Protocol conformance via @runtime_checkable
|
|
|
|
|
|
def test_ac1_protocol_runtime_checkable() -> None:
|
|
# Arrange
|
|
fake = _FakeEstimator()
|
|
|
|
# Assert
|
|
assert isinstance(fake, StateEstimator)
|
|
|
|
|
|
def test_ac1_missing_method_fails_isinstance() -> None:
|
|
class _Incomplete:
|
|
def add_vio(self, vio: VioOutput) -> None:
|
|
pass
|
|
|
|
# Assert — missing 6 methods → not a StateEstimator
|
|
assert not isinstance(_Incomplete(), StateEstimator)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: DTOs frozen + slots
|
|
|
|
|
|
def test_ac2_estimator_output_frozen_and_slotted() -> None:
|
|
out = _make_estimator_output()
|
|
# Assert — __slots__ exists and is non-empty
|
|
assert hasattr(EstimatorOutput, "__slots__")
|
|
assert len(EstimatorOutput.__slots__) > 0
|
|
# Assert — mutation raises
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
out.smoothed = True # type: ignore[misc]
|
|
|
|
|
|
def test_ac2_estimator_health_frozen_and_slotted() -> None:
|
|
h = _make_estimator_health()
|
|
assert hasattr(EstimatorHealth, "__slots__")
|
|
assert len(EstimatorHealth.__slots__) > 0
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
h.keyframe_count = 99 # type: ignore[misc]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: IsamState enum has 4 values
|
|
|
|
|
|
def test_ac3_isam_state_has_four_values() -> None:
|
|
members = {m.name for m in IsamState}
|
|
assert members == {"INIT", "TRACKING", "DEGRADED", "LOST"}
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: Factory rejects build-flag OFF
|
|
|
|
|
|
def test_ac4_build_flag_off_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
register_state_estimator("gtsam_isam2", lambda **_: (_FakeEstimator(), _fake_handle(None)))
|
|
monkeypatch.setenv("BUILD_STATE_GTSAM_ISAM2", "OFF")
|
|
|
|
cfg = _build_config(strategy="gtsam_isam2")
|
|
|
|
with pytest.raises(StateEstimatorConfigError, match="BUILD_STATE_GTSAM_ISAM2 is OFF"):
|
|
build_state_estimator(
|
|
cfg,
|
|
imu_preintegrator=mock.MagicMock(),
|
|
se3_utils=mock.MagicMock(),
|
|
wgs_converter=mock.MagicMock(),
|
|
fdr_client=mock.MagicMock(),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: Factory rejects unknown strategy at config-load
|
|
|
|
|
|
def test_ac5_unknown_strategy_rejected_at_config() -> None:
|
|
# Direct construction surfaces ConfigError (validation in __post_init__).
|
|
with pytest.raises(ConfigError, match="garbage"):
|
|
C5StateConfig(strategy="garbage")
|
|
|
|
|
|
def test_ac5_unknown_strategy_via_factory() -> None:
|
|
# If the block escapes (e.g. test sets strategy = "nonexistent" via a
|
|
# post-validation bypass), the factory still rejects with a clear
|
|
# StateEstimatorConfigError naming the disabled flag.
|
|
fake_block = mock.MagicMock(spec=C5StateConfig)
|
|
fake_block.strategy = "nonexistent"
|
|
fake_block.keyframe_window_size = 15
|
|
cfg = mock.MagicMock(spec=Config)
|
|
cfg.components = {"c5_state": fake_block}
|
|
|
|
with pytest.raises(StateEstimatorConfigError, match="not in"):
|
|
build_state_estimator(
|
|
cfg,
|
|
imu_preintegrator=mock.MagicMock(),
|
|
se3_utils=mock.MagicMock(),
|
|
wgs_converter=mock.MagicMock(),
|
|
fdr_client=mock.MagicMock(),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: Factory returns the tuple + INFO log
|
|
|
|
|
|
def test_ac6_factory_returns_tuple_and_logs(
|
|
caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
fake_estimator = _FakeEstimator()
|
|
fake_handle = _fake_handle(fake_estimator)
|
|
|
|
register_state_estimator(
|
|
"gtsam_isam2",
|
|
lambda **_: (fake_estimator, fake_handle),
|
|
)
|
|
monkeypatch.delenv("BUILD_STATE_GTSAM_ISAM2", raising=False)
|
|
|
|
cfg = _build_config(strategy="gtsam_isam2", keyframe_window_size=15)
|
|
|
|
with caplog.at_level(logging.INFO, logger="runtime_root.state_factory"):
|
|
result = build_state_estimator(
|
|
cfg,
|
|
imu_preintegrator=mock.MagicMock(),
|
|
se3_utils=mock.MagicMock(),
|
|
wgs_converter=mock.MagicMock(),
|
|
fdr_client=mock.MagicMock(),
|
|
)
|
|
|
|
estimator, handle = result
|
|
assert estimator is fake_estimator
|
|
assert handle is fake_handle
|
|
loaded_records = [
|
|
r for r in caplog.records if getattr(r, "kind", None) == "c5.state.strategy_loaded"
|
|
]
|
|
assert len(loaded_records) == 1
|
|
assert loaded_records[0].kv["strategy"] == "gtsam_isam2"
|
|
assert loaded_records[0].kv["keyframe_window_size"] == 15
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: Thread binding
|
|
|
|
|
|
def test_ac7_second_thread_binding_rejected() -> None:
|
|
main_ident = bind_state_ingest_thread()
|
|
|
|
err: list[BaseException] = []
|
|
|
|
def run() -> None:
|
|
try:
|
|
bind_state_ingest_thread()
|
|
except StateIngestThreadAlreadyBoundError as e:
|
|
err.append(e)
|
|
|
|
t = threading.Thread(target=run)
|
|
t.start()
|
|
t.join(timeout=2.0)
|
|
assert len(err) == 1
|
|
assert main_ident != threading.get_ident() + 1 # placeholder no-op assertion
|
|
|
|
|
|
def test_ac7_same_thread_rebinding_idempotent() -> None:
|
|
first = bind_state_ingest_thread()
|
|
second = bind_state_ingest_thread()
|
|
assert first == second
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: ISam2GraphHandleImpl skeleton
|
|
|
|
|
|
def test_ac8_handle_is_isam2_graph_handle() -> None:
|
|
handle = ISam2GraphHandleImpl(estimator=mock.MagicMock())
|
|
assert isinstance(handle, ISam2GraphHandle)
|
|
|
|
|
|
def test_handle_satisfies_c4_isam2_graph_handle_protocol() -> None:
|
|
"""Cross-component conformance: ``ISam2GraphHandleImpl`` MUST satisfy
|
|
the C4-side Protocol stub (``c4_pose._isam2_handle.ISam2GraphHandle``)
|
|
so the same instance can be passed to ``pose_factory.build_pose_estimator``
|
|
without an adapter. The c4 stub requires only ``get_pose_key(frame_id) -> int``;
|
|
the c5 impl delegates to ``estimator.key_for_frame`` for that lookup.
|
|
"""
|
|
# Arrange
|
|
from gps_denied_onboard.components.c4_pose._isam2_handle import (
|
|
ISam2GraphHandle as C4ISam2GraphHandle,
|
|
)
|
|
|
|
estimator_mock = mock.MagicMock()
|
|
estimator_mock.key_for_frame.return_value = 0x7800000000000007
|
|
handle = ISam2GraphHandleImpl(estimator=estimator_mock)
|
|
|
|
# Act
|
|
key = handle.get_pose_key(42)
|
|
|
|
# Assert
|
|
assert isinstance(handle, C4ISam2GraphHandle)
|
|
estimator_mock.key_for_frame.assert_called_once_with(42)
|
|
assert key == 0x7800000000000007
|
|
|
|
|
|
# Note: the AZ-381 skeleton's ``NotImplementedError`` bodies were
|
|
# replaced with real GTSAM calls by AZ-382. The "methods raise" test
|
|
# that lived here has moved to
|
|
# ``tests/unit/c5_state/test_az382_isam2_smoother_wiring.py``, which
|
|
# now asserts the real behaviour (``add_factor`` grows the graph,
|
|
# ``update`` advances iSAM2 + the smoother, etc.).
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: Public API re-exports
|
|
|
|
|
|
def test_ac9_public_api_resolves() -> None:
|
|
# Re-importing here makes the test self-documenting: every symbol below
|
|
# is part of the C5 public surface AC-9 requires.
|
|
from gps_denied_onboard.components.c5_state import ( # noqa: F401
|
|
C5StateConfig,
|
|
EstimatorHealth,
|
|
EstimatorOutput,
|
|
IsamState,
|
|
PoseSourceLabel,
|
|
StateEstimator,
|
|
)
|
|
|
|
|
|
def test_ac9_internals_not_in_all() -> None:
|
|
from gps_denied_onboard.components import c5_state
|
|
|
|
# _isam2_handle is an internal module; it must not be re-exported.
|
|
assert "ISam2GraphHandle" not in c5_state.__all__
|
|
assert "ISam2GraphHandleImpl" not in c5_state.__all__
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-10: Error hierarchy catchability
|
|
|
|
|
|
def test_ac10_every_error_is_state_estimator_error() -> None:
|
|
for exc_cls in (
|
|
EstimatorDegradedError,
|
|
EstimatorFatalError,
|
|
StateEstimatorConfigError,
|
|
):
|
|
with pytest.raises(StateEstimatorError):
|
|
raise exc_cls("test")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Config block validation
|
|
|
|
|
|
def test_config_keyframe_window_must_be_in_range() -> None:
|
|
with pytest.raises(ConfigError, match=r"\[10, 20\]"):
|
|
C5StateConfig(keyframe_window_size=5)
|
|
with pytest.raises(ConfigError, match=r"\[10, 20\]"):
|
|
C5StateConfig(keyframe_window_size=25)
|
|
# Boundaries OK.
|
|
C5StateConfig(keyframe_window_size=10)
|
|
C5StateConfig(keyframe_window_size=20)
|
|
|
|
|
|
def test_config_spoof_min_stable_must_be_positive() -> None:
|
|
with pytest.raises(ConfigError, match="spoof_promotion_min_stable_s"):
|
|
C5StateConfig(spoof_promotion_min_stable_s=0.0)
|
|
|
|
|
|
def test_config_no_estimate_fallback_must_be_positive() -> None:
|
|
with pytest.raises(ConfigError, match="no_estimate_fallback_s"):
|
|
C5StateConfig(no_estimate_fallback_s=-1.0)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Performance budget
|
|
|
|
|
|
def test_nfr_perf_build_under_50ms(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
import time as _t
|
|
|
|
register_state_estimator(
|
|
"gtsam_isam2",
|
|
lambda **_: (_FakeEstimator(), _fake_handle(None)),
|
|
)
|
|
monkeypatch.delenv("BUILD_STATE_GTSAM_ISAM2", raising=False)
|
|
cfg = _build_config(strategy="gtsam_isam2")
|
|
|
|
iters = 100
|
|
start = _t.perf_counter()
|
|
for _ in range(iters):
|
|
build_state_estimator(
|
|
cfg,
|
|
imu_preintegrator=mock.MagicMock(),
|
|
se3_utils=mock.MagicMock(),
|
|
wgs_converter=mock.MagicMock(),
|
|
fdr_client=mock.MagicMock(),
|
|
)
|
|
avg_ms = (_t.perf_counter() - start) / iters * 1000.0
|
|
assert avg_ms < 50.0, f"avg build {avg_ms:.2f}ms exceeds 50ms p99 budget"
|