Files
gps-denied-onboard/src/gps_denied_onboard/runtime_root/pose_factory.py
T
Oleksandr Bezdieniezhnykh db27e25630 [AZ-355] C4 PoseEstimator Protocol + factory + DTOs + composition
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>
2026-05-11 10:32:14 +03:00

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