[AZ-381] C5 StateEstimator protocol + factory + C8 DTO reshape

- 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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 05:35:20 +03:00
parent 8a9cf88a46
commit beed43724f
32 changed files with 1394 additions and 157 deletions
@@ -1,6 +1,53 @@
"""C5 State Estimator component — Public API."""
"""C5 State Estimator component — public API.
from gps_denied_onboard._types.pose import EstimatorHealth, EstimatorOutput
Per the C5 contract (``state_estimator_protocol.md`` v1.0.0), the
public surface consists of:
- :class:`StateEstimator` Protocol
- :class:`EstimatorOutput`, :class:`EstimatorHealth`, :class:`IsamState`
DTOs (in ``_types/state.py``)
- :class:`PoseSourceLabel` enum (in ``_types/state.py``; shared with C4)
- :class:`C5StateConfig` config block (registered on import)
- Error hierarchy: :class:`StateEstimatorError` and three subclasses
The ``ISam2GraphHandle`` Protocol + ``ISam2GraphHandleImpl`` skeleton
live in the private ``_isam2_handle`` module — consumers import them
from the composition root, not from here.
"""
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
IsamState,
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.components.c5_state.config import C5StateConfig
from gps_denied_onboard.components.c5_state.errors import (
EstimatorDegradedError,
EstimatorFatalError,
StateEstimatorConfigError,
StateEstimatorError,
)
from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.config.schema import register_component_block
__all__ = ["EstimatorHealth", "EstimatorOutput", "StateEstimator"]
__all__ = [
"C5StateConfig",
"EstimatorDegradedError",
"EstimatorFatalError",
"EstimatorHealth",
"EstimatorOutput",
"IsamState",
"PoseSourceLabel",
"Quat",
"StateEstimator",
"StateEstimatorConfigError",
"StateEstimatorError",
]
# Register the c5_state config block on import. The composition root
# loads this module before `load_config(...)` so the block is in the
# registry by the time YAML/env overrides resolve.
register_component_block("c5_state", C5StateConfig)
@@ -0,0 +1,84 @@
"""Concrete ``ISam2GraphHandle`` skeleton — AZ-381.
C4 (``OpenCVGtsamPoseEstimator``) calls ``add_factor`` / ``update`` /
``compute_marginals`` against this handle, NOT against C5 directly —
ADR-003 says C5 owns the graph; this handle is the typed seam C4 uses
to drive it without importing C5 internals.
AZ-381 ships the skeleton: every method raises
``NotImplementedError("Body owned by AZ-382 iSAM2 wiring task")``. The
``NotImplementedError`` messages name AZ-382 so the next task's
implementer can grep for them.
AZ-382 replaces the four method bodies with the real GTSAM calls
against the C5 estimator's ``_isam2`` + ``_smoother`` instances. The
Protocol surface is stable from AZ-381 onward.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from gps_denied_onboard.components.c5_state.gtsam_isam2_estimator import (
GtsamIsam2StateEstimator,
)
__all__ = ["ISam2GraphHandle", "ISam2GraphHandleImpl"]
@runtime_checkable
class ISam2GraphHandle(Protocol):
"""C4 ↔ C5 seam over the shared iSAM2 graph (ADR-003).
Owned by C5; held by reference inside ``OpenCVGtsamPoseEstimator``.
The handle is passed during composition (``state_factory``
returns the tuple; ``pose_factory`` accepts it as a positional
argument) and never crosses thread boundaries — see Invariant 1
of both the C4 and C5 contracts.
"""
def add_factor(self, factor: Any) -> None: ...
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None: ...
def compute_marginals(self) -> Any: ...
def last_anchor_age_ms(self) -> int: ...
class ISam2GraphHandleImpl(ISam2GraphHandle):
"""Skeleton — every method delegates to AZ-382 once that task lands.
The skeleton exists so AZ-381 can ship a runnable composition
root that produces a concrete handle reference for C4 to inject
against (per ADR-009). AZ-382 replaces every body with the real
GTSAM calls; the Protocol surface does not change.
"""
def __init__(self, estimator: GtsamIsam2StateEstimator) -> None:
self._estimator = estimator
def add_factor(self, factor: Any) -> None:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def update(self, graph: Any, values: Any, timestamps: Any | None = None) -> None:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def compute_marginals(self) -> Any:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
def last_anchor_age_ms(self) -> int:
raise NotImplementedError(
"Body owned by AZ-382 iSAM2 wiring task — "
"this skeleton is intentionally inert until iSAM2 wiring lands."
)
@@ -0,0 +1,75 @@
"""C5 state estimator config block — AZ-381.
The block is registered into the global config registry via
``register_component_block("c5_state", C5StateConfig)``; the runtime
root reads ``config.components["c5_state"]`` and dispatches by
``strategy``. ADR-002 build-time-exclusion gating happens in
:mod:`gps_denied_onboard.runtime_root.state_factory`, not here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from gps_denied_onboard.config.schema import ConfigError
__all__ = ["KNOWN_STATE_STRATEGIES", "C5StateConfig"]
KNOWN_STATE_STRATEGIES: Final[frozenset[str]] = frozenset({"gtsam_isam2", "eskf"})
_KEYFRAME_WINDOW_MIN: Final[int] = 10
_KEYFRAME_WINDOW_MAX: Final[int] = 20
@dataclass(frozen=True)
class C5StateConfig:
"""C5 state-estimator config block.
Fields per the C5 contract §"Config schema additions":
- ``strategy`` — selects between ``"gtsam_isam2"`` (production
default) and ``"eskf"`` (mandatory simple baseline per IT-12).
- ``keyframe_window_size`` — D-C5-3 K∈[10,20] for the
``IncrementalFixedLagSmoother`` window.
- ``spoof_promotion_min_stable_s`` — AC-NEW-2 minimum dwell time
in ``STABLE_NON_SPOOFED`` before the spoof-promotion gate opens.
- ``spoof_promotion_visual_consistency_tol_m`` — AC-NEW-8 visual
consistency tolerance on the next anchor.
- ``no_estimate_fallback_s`` — AC-5.2 timeout before the
runtime root drops to FC-IMU-only mode.
"""
strategy: str = "gtsam_isam2"
keyframe_window_size: int = 15
spoof_promotion_min_stable_s: float = 10.0
spoof_promotion_visual_consistency_tol_m: float = 30.0
no_estimate_fallback_s: float = 3.0
def __post_init__(self) -> None:
if self.strategy not in KNOWN_STATE_STRATEGIES:
raise ConfigError(
f"C5StateConfig.strategy={self.strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
)
if not (_KEYFRAME_WINDOW_MIN <= self.keyframe_window_size <= _KEYFRAME_WINDOW_MAX):
raise ConfigError(
"C5StateConfig.keyframe_window_size must be in "
f"[{_KEYFRAME_WINDOW_MIN}, {_KEYFRAME_WINDOW_MAX}] (D-C5-3); "
f"got {self.keyframe_window_size}"
)
if self.spoof_promotion_min_stable_s <= 0.0:
raise ConfigError(
"C5StateConfig.spoof_promotion_min_stable_s must be > 0; "
f"got {self.spoof_promotion_min_stable_s}"
)
if self.spoof_promotion_visual_consistency_tol_m <= 0.0:
raise ConfigError(
"C5StateConfig.spoof_promotion_visual_consistency_tol_m must be > 0; "
f"got {self.spoof_promotion_visual_consistency_tol_m}"
)
if self.no_estimate_fallback_s <= 0.0:
raise ConfigError(
"C5StateConfig.no_estimate_fallback_s must be > 0; "
f"got {self.no_estimate_fallback_s}"
)
@@ -0,0 +1,55 @@
"""C5 ``StateEstimator`` error hierarchy — AZ-381.
Every C5-emitted exception inherits :class:`StateEstimatorError`
(AC-10) so callers can write a single ``except`` against the whole
component surface. Composition-root failures use
:class:`StateEstimatorConfigError`; runtime failures split into
``Degraded`` (recoverable; emit a degraded estimate + log) vs
``Fatal`` (unrecoverable; trigger the AC-5.2 IMU-only fallback path
in C8).
"""
from __future__ import annotations
__all__ = [
"EstimatorDegradedError",
"EstimatorFatalError",
"StateEstimatorConfigError",
"StateEstimatorError",
]
class StateEstimatorError(Exception):
"""Base class for every C5-emitted exception (AC-10)."""
class EstimatorDegradedError(StateEstimatorError):
"""Recoverable runtime degradation.
Examples: out-of-order ``add_*`` call (Invariant 2), failed factor
add against the graph (R05 mitigation surfaces via this), poor
convergence detected post-update. The estimator continues to
produce outputs but the next ``current_estimate()`` may carry a
degraded ``EstimatorHealth.isam2_state``.
"""
class EstimatorFatalError(StateEstimatorError):
"""Unrecoverable numerical failure.
Raised when iSAM2 / Marginals / the smoother enter a state from
which the run cannot continue: non-SPD posterior covariance after
update, NaN propagation, GTSAM exception bubbling. Triggers the
AC-5.2 path in C8 (IMU-only fallback) and the source-label state
machine transitions to ``DEAD_RECKONED``.
"""
class StateEstimatorConfigError(StateEstimatorError):
"""Composition-time configuration error.
Raised by :func:`build_state_estimator` when the requested
strategy is not registered (per ADR-002 build flag gating), when
the config schema fails validation, or when the runtime root
cannot wire the iSAM2 graph handle into C4.
"""
@@ -1,23 +1,70 @@
"""C5 `StateEstimator` Protocol.
"""C5 ``StateEstimator`` Protocol — AZ-381.
Concrete impls: `GtsamIsam2StateEstimator` (production-default; iSAM2 +
IncrementalFixedLagSmoother), `EskfStateEstimator` (mandatory simple baseline).
See `_docs/02_document/components/07_c5_state/`.
The single typed handle C8 / C4 / runtime root hold for the state
estimator. Concrete implementations live in
``gps_denied_onboard.components.c5_state.gtsam_isam2_estimator`` (AZ-382
onward) and ``...eskf_baseline`` (AZ-386). Both are link-time exclusive
via the ``BUILD_STATE_<variant>`` flags per ADR-002.
The Protocol is ``runtime_checkable`` so test fakes pass
``isinstance(fake, StateEstimator)`` without depending on a concrete
parent class (ADR-009 — interface-first DI).
See the contract at
``_docs/02_document/contracts/c5_state/state_estimator_protocol.md``
for the complete invariant list (10 invariants total).
"""
from __future__ import annotations
from typing import Protocol
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from gps_denied_onboard._types.pose import EstimatorOutput, PoseEstimate
from gps_denied_onboard._types.vio import VioOutput
if TYPE_CHECKING:
from gps_denied_onboard._types.fc import ImuTelemetrySample
from gps_denied_onboard._types.pose import PoseEstimate
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
)
from gps_denied_onboard._types.vio import VioOutput
__all__ = ["StateEstimator"]
@runtime_checkable
class StateEstimator(Protocol):
"""Smoothed state estimator (fuses VIO + satellite anchors + IMU)."""
"""Smoothed state estimator (fuses VIO + satellite anchors + IMU).
def add_vio(self, vio: VioOutput) -> None: ...
All six methods run on the same ingest thread (Invariant 1 —
GTSAM iSAM2 is not thread-safe; composition root enforces). The
six methods correspond 1:1 to the contract surface; concrete
impls must implement every method.
"""
def add_pose_anchor(self, anchor: PoseEstimate) -> None: ...
def add_vio(self, vio: VioOutput) -> None:
"""Add a VIO output as a relative-pose factor to the iSAM2 graph."""
def latest_output(self) -> EstimatorOutput | None: ...
def add_pose_anchor(self, pose: PoseEstimate) -> None:
"""Add a C4 pose anchor.
Invariant 3 — ``pose.covariance_mode == "jacobian"`` MUST NOT
produce an iSAM2 factor; only the marginals path triggers the
factor + update cycle.
"""
def add_fc_imu(self, imu_window: ImuTelemetrySample) -> None:
"""Add an FC IMU sample / window to the iSAM2 preintegrator."""
def current_estimate(self) -> EstimatorOutput:
"""Return the latest (non-smoothed) estimate. Never returns ``None``."""
def smoothed_history(self, n_keyframes: int) -> list[EstimatorOutput]:
"""Return up to ``n_keyframes`` recent smoothed estimates.
Every entry has ``smoothed=True`` (Invariant 7); never emitted
to the FC (Invariant 6); bounded by the keyframe window K
(Invariant 6 of the contract; D-C5-3 K∈[10,20]).
"""
def health_snapshot(self) -> EstimatorHealth:
"""Return the current iSAM2 health snapshot."""