mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 08:41:12 +00:00
db27e25630
Land the foundational C4 surface AZ-358 (Marginals) and AZ-361 (Hybrid) build on top of: - PoseEstimator Protocol (@runtime_checkable): estimate(...) + current_covariance_mode(). - Error hierarchy: PoseEstimatorError, PnpFailureError, PoseEstimatorConfigError; CovarianceDegradedWarning as a Warning subclass (warnings.warn path, not raised). - ISam2GraphHandle Protocol stub (READ-ONLY view, get_pose_key only) decoupled from C5's concrete ISam2GraphHandleImpl. - C4PoseConfig (frozen dataclass) + register on c4_pose import. - runtime_root/pose_factory.build_pose_estimator with lazy-import fallback; INFO log c4.pose.strategy_loaded; shares ingest-thread binding with C5 per ADR-003. DTO restructuring (cross-cutting): retire the legacy raw-4x4 PoseEstimate(int frame_id, datetime timestamp, pose_se3, ...) and ship the contract shape PoseEstimate(UUID, LatLonAlt, Quat, np.ndarray, CovarianceMode, PoseSourceLabel, last_satellite_anchor_age_ms, emitted_at). C5 add_pose_anchor in both gtsam_isam2 + eskf_baseline migrated in lockstep via WGS84->ENU + Quat->R helpers; test fixtures updated. VIO output stays on the raw shape until AZ-331 (C1 protocol) lands. LatLonAlt upgraded to slots=True per AC-2. ThermalState stub added to _types/thermal.py so the Protocol typechecks pre-AZ-302. Tests: 25 new in tests/unit/c4_pose/test_az355_pose_protocol.py covering AC-1..AC-10 + factory wiring + config validation; full repo: 685 passed, 2 pre-existing CI-only skips. Jira transition deferred: MCP "Not connected"; leftover entry in _docs/_process_leftovers/2026-05-11_jira_transition_az355_deferred.md. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""Composition-root factory for C4 pose estimator (AZ-355 / E-C4).
|
|
|
|
Mirrors :mod:`gps_denied_onboard.runtime_root.state_factory` — per
|
|
ADR-001 the composition root is the single registration site for
|
|
all strategy factories. C4 has exactly ONE concrete strategy
|
|
(``opencv_gtsam`` → AZ-358); the Protocol exists for ADR-009
|
|
(interface-first DI) so callers don't import the concrete class.
|
|
|
|
The runtime root constructs the dependencies (RansacFilter,
|
|
WgsConverter, SE3Utils, ISam2GraphHandle) ONCE and passes references
|
|
through this factory. The factory does NOT instantiate them.
|
|
|
|
Per the C4 contract Invariant 1 + AC-9: the C4 estimator is bound
|
|
to the SAME ingest thread as C5 (ADR-003 shared GTSAM substrate is
|
|
not thread-safe). The thread-binding helper is shared with the C5
|
|
state factory (``bind_state_ingest_thread``); a second binding
|
|
from a different thread raises
|
|
:class:`StateIngestThreadAlreadyBoundError`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
from gps_denied_onboard.components.c4_pose._isam2_handle import ISam2GraphHandle
|
|
from gps_denied_onboard.components.c4_pose.config import (
|
|
KNOWN_POSE_STRATEGIES,
|
|
C4PoseConfig,
|
|
)
|
|
from gps_denied_onboard.components.c4_pose.errors import PoseEstimatorConfigError
|
|
from gps_denied_onboard.components.c4_pose.interface import PoseEstimator
|
|
from gps_denied_onboard.logging import get_logger
|
|
|
|
if TYPE_CHECKING:
|
|
from gps_denied_onboard.config import Config
|
|
|
|
__all__ = [
|
|
"PoseEstimatorFactory",
|
|
"build_pose_estimator",
|
|
"clear_pose_registry",
|
|
"list_registered_pose_strategies",
|
|
"register_pose_estimator",
|
|
]
|
|
|
|
|
|
PoseEstimatorFactory = Callable[..., PoseEstimator]
|
|
|
|
_POSE_REGISTRY: dict[str, PoseEstimatorFactory] = {}
|
|
|
|
# Single concrete strategy (ADR-001); module path for lazy-import
|
|
# fallback when the test/binary did not pre-register.
|
|
_STRATEGY_MODULE_PATHS: Final[dict[str, str]] = {
|
|
"opencv_gtsam": "gps_denied_onboard.components.c4_pose.opencv_gtsam_estimator",
|
|
}
|
|
|
|
|
|
def register_pose_estimator(strategy: str, factory: PoseEstimatorFactory) -> None:
|
|
"""Register a concrete ``PoseEstimator`` strategy.
|
|
|
|
Duplicate registration with a different factory raises
|
|
:class:`PoseEstimatorConfigError`.
|
|
"""
|
|
existing = _POSE_REGISTRY.get(strategy)
|
|
if existing is not None and existing is not factory:
|
|
raise PoseEstimatorConfigError(
|
|
f"duplicate PoseEstimator registration for strategy {strategy!r}"
|
|
)
|
|
_POSE_REGISTRY[strategy] = factory
|
|
|
|
|
|
def clear_pose_registry() -> None:
|
|
"""Reset the pose registry; unit-test isolation only."""
|
|
_POSE_REGISTRY.clear()
|
|
|
|
|
|
def list_registered_pose_strategies() -> list[str]:
|
|
return sorted(_POSE_REGISTRY)
|
|
|
|
|
|
def build_pose_estimator(
|
|
config: Config,
|
|
*,
|
|
ransac_filter: Any,
|
|
wgs_converter: Any,
|
|
se3_utils: Any,
|
|
isam2_graph_handle: ISam2GraphHandle,
|
|
) -> PoseEstimator:
|
|
"""Resolve + build the configured C4 pose estimator.
|
|
|
|
Validation order: config block lookup → strategy known? →
|
|
isam2_graph_handle conforms? → factory lookup (with lazy-import
|
|
fallback) → INFO log on success.
|
|
|
|
Raises:
|
|
PoseEstimatorConfigError: invalid config, unknown strategy,
|
|
non-conforming graph handle, or registry miss after
|
|
lazy-import fallback.
|
|
"""
|
|
block = _read_pose_block(config)
|
|
strategy = block.strategy
|
|
log = get_logger("runtime_root.pose_factory")
|
|
if strategy not in KNOWN_POSE_STRATEGIES:
|
|
log.error(
|
|
"c4.pose.unknown_strategy",
|
|
extra={
|
|
"kind": "c4.pose.unknown_strategy",
|
|
"kv": {
|
|
"strategy": strategy,
|
|
"known": sorted(KNOWN_POSE_STRATEGIES),
|
|
},
|
|
},
|
|
)
|
|
raise PoseEstimatorConfigError(
|
|
f"C4PoseConfig.strategy={strategy!r} not in {sorted(KNOWN_POSE_STRATEGIES)}"
|
|
)
|
|
if not isinstance(isam2_graph_handle, ISam2GraphHandle):
|
|
raise PoseEstimatorConfigError(
|
|
"build_pose_estimator: isam2_graph_handle does not satisfy "
|
|
"the C4 ISam2GraphHandle Protocol (missing get_pose_key?)"
|
|
)
|
|
|
|
factory = _resolve_factory(strategy)
|
|
estimator = factory(
|
|
config=config,
|
|
ransac_filter=ransac_filter,
|
|
wgs_converter=wgs_converter,
|
|
se3_utils=se3_utils,
|
|
isam2_graph_handle=isam2_graph_handle,
|
|
)
|
|
log.info(
|
|
f"c4.pose.strategy_loaded: strategy={strategy} "
|
|
f"ransac_iterations={block.ransac_iterations} "
|
|
f"ransac_reprojection_threshold_px={block.ransac_reprojection_threshold_px}",
|
|
extra={
|
|
"kind": "c4.pose.strategy_loaded",
|
|
"kv": {
|
|
"strategy": strategy,
|
|
"ransac_iterations": block.ransac_iterations,
|
|
"ransac_reprojection_threshold_px": block.ransac_reprojection_threshold_px,
|
|
"thermal_throttle_threshold_celsius": (block.thermal_throttle_threshold_celsius),
|
|
},
|
|
},
|
|
)
|
|
return estimator
|
|
|
|
|
|
def _read_pose_block(config: Config) -> C4PoseConfig:
|
|
components = getattr(config, "components", None) or {}
|
|
block = components.get("c4_pose") if isinstance(components, dict) else None
|
|
if block is None:
|
|
return C4PoseConfig()
|
|
if isinstance(block, C4PoseConfig):
|
|
return block
|
|
raise PoseEstimatorConfigError(
|
|
f"config.components['c4_pose'] must be a C4PoseConfig; got {type(block).__name__}"
|
|
)
|
|
|
|
|
|
def _resolve_factory(strategy: str) -> PoseEstimatorFactory:
|
|
factory = _POSE_REGISTRY.get(strategy)
|
|
if factory is not None:
|
|
return factory
|
|
module_path = _STRATEGY_MODULE_PATHS.get(strategy)
|
|
if module_path is None:
|
|
raise PoseEstimatorConfigError(
|
|
f"pose strategy {strategy!r} has no module-path mapping for lazy import"
|
|
)
|
|
try:
|
|
module = importlib.import_module(module_path)
|
|
except ImportError as exc:
|
|
raise PoseEstimatorConfigError(
|
|
f"pose strategy {strategy!r} module {module_path!r} not importable: {exc}"
|
|
) from exc
|
|
factory_obj = getattr(module, "create", None)
|
|
if factory_obj is None or not callable(factory_obj):
|
|
raise PoseEstimatorConfigError(
|
|
f"pose strategy {strategy!r} module {module_path!r} has no create(...) factory"
|
|
)
|
|
return factory_obj
|