mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:11:13 +00:00
[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:
@@ -40,6 +40,15 @@ from gps_denied_onboard.runtime_root.spoof_recovery_sink import (
|
||||
SpoofRecoveryPublisher,
|
||||
SpoofRecoverySink,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.state_factory import (
|
||||
StateIngestThreadAlreadyBoundError,
|
||||
bind_state_ingest_thread,
|
||||
build_state_estimator,
|
||||
clear_state_ingest_binding,
|
||||
clear_state_registry,
|
||||
list_registered_state_strategies,
|
||||
register_state_estimator,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
|
||||
@@ -55,13 +64,18 @@ __all__ = [
|
||||
"RuntimeRoot",
|
||||
"SpoofRecoveryPublisher",
|
||||
"SpoofRecoverySink",
|
||||
"StateIngestThreadAlreadyBoundError",
|
||||
"StrategyNotLinkedError",
|
||||
"StrategyTier",
|
||||
"TakeoffResult",
|
||||
"bind_outbound_emit_thread",
|
||||
"bind_state_ingest_thread",
|
||||
"build_fc_adapter",
|
||||
"build_gcs_adapter",
|
||||
"build_state_estimator",
|
||||
"clear_outbound_thread_binding",
|
||||
"clear_state_ingest_binding",
|
||||
"clear_state_registry",
|
||||
"clear_strategy_registries",
|
||||
"clear_strategy_registry",
|
||||
"compose_operator",
|
||||
@@ -69,10 +83,12 @@ __all__ = [
|
||||
"compose_root",
|
||||
"list_registered_fc_strategies",
|
||||
"list_registered_gcs_strategies",
|
||||
"list_registered_state_strategies",
|
||||
"list_registered_strategies",
|
||||
"main",
|
||||
"register_fc_adapter",
|
||||
"register_gcs_adapter",
|
||||
"register_state_estimator",
|
||||
"register_strategy",
|
||||
"take_off",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Composition-root factory for C5 (AZ-381 / E-C5).
|
||||
|
||||
Mirrors the C8 factory shape (``runtime_root.fc_factory``): per-binary
|
||||
bootstrap modules register concrete strategy factories under their
|
||||
``BUILD_STATE_<variant>`` flag; :func:`build_state_estimator` resolves
|
||||
the configured strategy, gates by build flag, constructs the
|
||||
estimator, and returns the tuple ``(StateEstimator,
|
||||
ISam2GraphHandle)`` so the runtime root can inject the handle into
|
||||
C4.
|
||||
|
||||
Single-writer-thread binding for C5 + C4 is enforced via
|
||||
:func:`bind_state_ingest_thread`; the second binding from a different
|
||||
thread raises :class:`StateIngestThreadAlreadyBoundError`. The runtime
|
||||
root binds C4 + C5 to the SAME thread.
|
||||
|
||||
ADR-002 build-flag gating: ``config.components["c5_state"].strategy ==
|
||||
"gtsam_isam2"`` requires ``BUILD_STATE_GTSAM_ISAM2=ON``. Default is
|
||||
``ON`` (most binaries link the production-default); the operator-side
|
||||
binary may set ``OFF`` and only link the ESKF baseline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from gps_denied_onboard.components.c5_state._isam2_handle import ISam2GraphHandle
|
||||
from gps_denied_onboard.components.c5_state.config import (
|
||||
KNOWN_STATE_STRATEGIES,
|
||||
C5StateConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
|
||||
from gps_denied_onboard.components.c5_state.interface import StateEstimator
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
"StateEstimatorFactory",
|
||||
"StateIngestThreadAlreadyBoundError",
|
||||
"bind_state_ingest_thread",
|
||||
"build_state_estimator",
|
||||
"clear_state_ingest_binding",
|
||||
"clear_state_registry",
|
||||
"list_registered_state_strategies",
|
||||
"register_state_estimator",
|
||||
]
|
||||
|
||||
|
||||
StateEstimatorFactory = Callable[..., tuple[StateEstimator, ISam2GraphHandle]]
|
||||
|
||||
_STATE_REGISTRY: dict[str, StateEstimatorFactory] = {}
|
||||
|
||||
_STATE_BUILD_FLAGS: Final[dict[str, str]] = {
|
||||
"gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
|
||||
"eskf": "BUILD_STATE_ESKF",
|
||||
}
|
||||
|
||||
|
||||
def register_state_estimator(strategy: str, factory: StateEstimatorFactory) -> None:
|
||||
"""Register a concrete `StateEstimator` strategy.
|
||||
|
||||
Called by per-binary bootstrap modules under the matching
|
||||
``BUILD_STATE_<variant>`` flag. Duplicate registration with a
|
||||
different factory raises :class:`StateEstimatorConfigError`.
|
||||
"""
|
||||
existing = _STATE_REGISTRY.get(strategy)
|
||||
if existing is not None and existing is not factory:
|
||||
raise StateEstimatorConfigError(
|
||||
f"duplicate StateEstimator registration for strategy {strategy!r}"
|
||||
)
|
||||
_STATE_REGISTRY[strategy] = factory
|
||||
|
||||
|
||||
def clear_state_registry() -> None:
|
||||
"""Reset the state registry; unit-test isolation only."""
|
||||
_STATE_REGISTRY.clear()
|
||||
|
||||
|
||||
def list_registered_state_strategies() -> list[str]:
|
||||
return sorted(_STATE_REGISTRY)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Single-writer state-ingest thread (Invariant 1; C4 + C5 share it).
|
||||
|
||||
|
||||
class StateIngestThreadAlreadyBoundError(RuntimeError):
|
||||
"""Raised on a second :func:`bind_state_ingest_thread` call from a different thread."""
|
||||
|
||||
|
||||
_ingest_lock = threading.Lock()
|
||||
_ingest_bound_thread: int | None = None
|
||||
|
||||
|
||||
def bind_state_ingest_thread(thread_ident: int | None = None) -> int:
|
||||
"""Bind ``thread_ident`` (defaults to the caller) as the sole state-ingest thread.
|
||||
|
||||
A second call from a different thread raises
|
||||
:class:`StateIngestThreadAlreadyBoundError`. C4 + C5 + the shared
|
||||
GTSAM substrate live on this thread per ADR-003. Repeated binding
|
||||
from the SAME thread is permitted (idempotent for re-entrant
|
||||
composition under tests).
|
||||
"""
|
||||
global _ingest_bound_thread
|
||||
ident = thread_ident if thread_ident is not None else threading.get_ident()
|
||||
with _ingest_lock:
|
||||
if _ingest_bound_thread is not None and _ingest_bound_thread != ident:
|
||||
raise StateIngestThreadAlreadyBoundError(
|
||||
f"state ingest thread already bound to {_ingest_bound_thread}; "
|
||||
f"refused to re-bind to {ident}"
|
||||
)
|
||||
_ingest_bound_thread = ident
|
||||
return ident
|
||||
|
||||
|
||||
def clear_state_ingest_binding() -> None:
|
||||
"""Reset the state-ingest-thread binding; unit-test isolation only."""
|
||||
global _ingest_bound_thread
|
||||
with _ingest_lock:
|
||||
_ingest_bound_thread = None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Public composition-root factory.
|
||||
|
||||
|
||||
def build_state_estimator(
|
||||
config: Config,
|
||||
*,
|
||||
imu_preintegrator: Any,
|
||||
se3_utils: Any,
|
||||
wgs_converter: Any,
|
||||
fdr_client: Any,
|
||||
) -> tuple[StateEstimator, ISam2GraphHandle]:
|
||||
"""Resolve + build the configured state estimator.
|
||||
|
||||
Returns the ``(StateEstimator, ISam2GraphHandle)`` tuple so the
|
||||
runtime root can inject the handle into C4 via
|
||||
``build_pose_estimator``.
|
||||
|
||||
Validation order: config block lookup → build-flag gate → factory
|
||||
lookup. The first failure surfaces a :class:`StateEstimatorConfigError`
|
||||
with the offending strategy + flag name so the operator gets a
|
||||
clear next step.
|
||||
"""
|
||||
block = _read_state_block(config)
|
||||
strategy = block.strategy
|
||||
if strategy not in KNOWN_STATE_STRATEGIES:
|
||||
raise StateEstimatorConfigError(
|
||||
f"C5StateConfig.strategy={strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
|
||||
)
|
||||
flag_name = _STATE_BUILD_FLAGS.get(strategy)
|
||||
if flag_name is None:
|
||||
raise StateEstimatorConfigError(
|
||||
f"state strategy {strategy!r} has no BUILD_STATE_* flag mapping"
|
||||
)
|
||||
if os.environ.get(flag_name, "ON").upper() == "OFF":
|
||||
raise StateEstimatorConfigError(
|
||||
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
|
||||
)
|
||||
factory = _STATE_REGISTRY.get(strategy)
|
||||
if factory is None:
|
||||
raise StateEstimatorConfigError(
|
||||
f"state strategy {strategy!r} selected by config but not registered; "
|
||||
f"registered strategies: {list_registered_state_strategies()}"
|
||||
)
|
||||
estimator, handle = factory(
|
||||
config=config,
|
||||
imu_preintegrator=imu_preintegrator,
|
||||
se3_utils=se3_utils,
|
||||
wgs_converter=wgs_converter,
|
||||
fdr_client=fdr_client,
|
||||
)
|
||||
_log_strategy_loaded(
|
||||
strategy=strategy,
|
||||
keyframe_window_size=block.keyframe_window_size,
|
||||
)
|
||||
return estimator, handle
|
||||
|
||||
|
||||
def _read_state_block(config: Config) -> C5StateConfig:
|
||||
"""Pull the c5_state block out of ``config.components`` (or fall back to defaults)."""
|
||||
components = getattr(config, "components", None) or {}
|
||||
block = components.get("c5_state") if isinstance(components, dict) else None
|
||||
if block is None:
|
||||
# Allow missing block to mean "documented defaults" — same shape
|
||||
# as the cross-cutting blocks. Tests that exercise the factory
|
||||
# without YAML/env see defaults.
|
||||
return C5StateConfig()
|
||||
if isinstance(block, C5StateConfig):
|
||||
return block
|
||||
raise StateEstimatorConfigError(
|
||||
f"config.components['c5_state'] must be a C5StateConfig; got {type(block).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _log_strategy_loaded(*, strategy: str, keyframe_window_size: int) -> None:
|
||||
log = get_logger("runtime_root.state_factory")
|
||||
log.info(
|
||||
f"c5.state.strategy_loaded: strategy={strategy} "
|
||||
f"keyframe_window_size={keyframe_window_size}",
|
||||
extra={
|
||||
"kind": "c5.state.strategy_loaded",
|
||||
"kv": {
|
||||
"strategy": strategy,
|
||||
"keyframe_window_size": keyframe_window_size,
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user