[AZ-331] C1 VioStrategy: Protocol + DTOs + factory + C5 migration

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 04:44:31 +03:00
parent daff5d4d1c
commit 6c7d24f7e0
20 changed files with 1027 additions and 81 deletions
@@ -449,9 +449,9 @@ class EskfStateEstimator(StateEstimator):
residual in the previous body frame.
"""
self._close_cold_start_window()
ts_ns = _datetime_to_ns(vio.timestamp)
ts_ns = vio.emitted_at_ns
self._guard_timestamp(ts_ns, source="vio")
curr_pose = _pose_se3_to_array(vio.pose_se3)
curr_pose = vio.relative_pose_T.matrix()
if self._prev_vio_pose is None:
self._prev_vio_pose = curr_pose
@@ -498,7 +498,7 @@ class EskfStateEstimator(StateEstimator):
H = np.zeros((6, _N_STATE), dtype=np.float64)
H[0:3, _IDX_POS] = np.eye(3)
H[3:6, _IDX_ROT] = prev_R # rotate body-frame perturbation back to world
R_meas = _measurement_noise(vio.covariance_6x6)
R_meas = _measurement_noise(vio.pose_covariance_6x6)
try:
self._kalman_update(H, residual, R_meas, source="vio")
@@ -6,7 +6,7 @@ real bodies. AZ-383 owns the three Protocol factor-add methods:
* ``add_vio(vio: VioOutput)`` — ``BetweenFactorPose3`` between
consecutive pose keys with a noise model derived from
``vio.covariance_6x6``.
``vio.pose_covariance_6x6``.
* ``add_pose_anchor(pose: PoseEstimate)`` — mode-dispatched per
``pose.covariance_mode``: ``"marginals"`` → ``PriorFactorPose3`` +
``update``; ``"jacobian"`` → skip iSAM2 add (per the AZ-361 cross-task
@@ -403,7 +403,7 @@ class GtsamIsam2StateEstimator(StateEstimator):
"""Register a callback fired exactly once per fallback recovery."""
return self._fallback.subscribe_recovered(callback)
def key_for_frame(self, frame_id: UUID | int) -> int:
def key_for_frame(self, frame_id: UUID | int | str) -> int:
"""Return the GTSAM ``Key`` for ``frame_id``, assigning on first use.
AZ-383 calls this from ``add_vio`` and ``add_pose_anchor`` to
@@ -653,10 +653,10 @@ class GtsamIsam2StateEstimator(StateEstimator):
"""
handle = self._require_handle()
self._close_cold_start_window()
ts_ns = _datetime_to_ns(vio.timestamp)
ts_ns = vio.emitted_at_ns
self._guard_timestamp(ts_ns, source="vio")
curr_pose = _pose_se3_to_gtsam(vio.pose_se3)
curr_pose = vio.relative_pose_T
curr_key = self.key_for_frame(vio.frame_id)
if self._prev_vio is None:
@@ -674,10 +674,10 @@ class GtsamIsam2StateEstimator(StateEstimator):
)
return
prev_pose = _pose_se3_to_gtsam(self._prev_vio.pose_se3)
prev_pose = self._prev_vio.relative_pose_T
prev_key = self.key_for_frame(self._prev_vio.frame_id)
relative_pose = prev_pose.between(curr_pose)
noise = _build_pose_noise(vio.covariance_6x6)
noise = _build_pose_noise(vio.pose_covariance_6x6)
factor = gtsam.BetweenFactorPose3(prev_key, curr_key, relative_pose, noise)
try:
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
EstimatorHealth,
EstimatorOutput,
)
from gps_denied_onboard._types.vio import VioOutput
from gps_denied_onboard._types.nav import VioOutput
__all__ = ["StateEstimator"]