"""AZ-919 — `AltitudeProvider` Protocol + `EskfNominalAltitudeProvider` contract. Pins the gating rules that the GSD scale-recovery work (AZ-920) and the degraded-mode signal (AZ-921) build on top of. """ from __future__ import annotations from typing import Any import numpy as np from gps_denied_onboard._types.state import IsamState from gps_denied_onboard.helpers.altitude_provider import ( AltitudeProvider, EskfNominalAltitudeProvider, ) class _FakeEstimator: """Minimal ESKF stand-in exposing only the attrs the provider reads.""" def __init__( self, *, nominal_pos_z: float, takeoff_origin_set: bool, isam2_state: IsamState, ) -> None: self._nominal_pos = np.array([0.0, 0.0, nominal_pos_z], dtype=np.float64) self._takeoff_origin_set: tuple[Any, float, float] | None = ( ("origin-sentinel", 1.0, 1.0) if takeoff_origin_set else None ) self._isam2_state = isam2_state def test_provider_is_runtime_checkable_protocol() -> None: provider = EskfNominalAltitudeProvider(estimator_supplier=lambda: None) assert isinstance(provider, AltitudeProvider) def test_returns_none_when_supplier_yields_none() -> None: provider = EskfNominalAltitudeProvider(estimator_supplier=lambda: None) assert provider.agl_m(now_ns=1_000_000_000) is None def test_returns_none_before_takeoff_origin_is_anchored() -> None: # Arrange — origin not yet set, even though nominal_pos has a value. estimator = _FakeEstimator( nominal_pos_z=42.0, takeoff_origin_set=False, isam2_state=IsamState.INIT, ) provider = EskfNominalAltitudeProvider(estimator_supplier=lambda: estimator) assert provider.agl_m(now_ns=1_000_000_000) is None def test_returns_nominal_pos_z_after_origin_is_anchored() -> None: # Arrange estimator = _FakeEstimator( nominal_pos_z=83.5, takeoff_origin_set=True, isam2_state=IsamState.TRACKING, ) provider = EskfNominalAltitudeProvider(estimator_supplier=lambda: estimator) # Act + Assert assert provider.agl_m(now_ns=1_000_000_000) == 83.5 def test_returns_none_when_estimator_is_lost() -> None: # Arrange — origin anchored and altitude is non-zero, but the # filter has flipped to LOST; the AGL signal is no longer trustworthy. estimator = _FakeEstimator( nominal_pos_z=120.0, takeoff_origin_set=True, isam2_state=IsamState.LOST, ) provider = EskfNominalAltitudeProvider(estimator_supplier=lambda: estimator) assert provider.agl_m(now_ns=1_000_000_000) is None def test_supplier_is_re_resolved_per_call() -> None: # Arrange — the composition root builds C1 before C5, so the # supplier must read from a mutable container at call time. holder: dict[str, _FakeEstimator] = {} provider = EskfNominalAltitudeProvider( estimator_supplier=lambda: holder.get("c5_state"), ) # Act — first call: C5 not yet built. first = provider.agl_m(now_ns=1_000_000_000) # Then C5 lands in the dict. holder["c5_state"] = _FakeEstimator( nominal_pos_z=37.0, takeoff_origin_set=True, isam2_state=IsamState.TRACKING, ) second = provider.agl_m(now_ns=2_000_000_000) # Assert assert first is None assert second == 37.0