Files
gps-denied-onboard/src/gps_denied_onboard/runtime_root/pose_factory.py
T
Oleksandr Bezdieniezhnykh 4eac24f37a [AZ-358] [AZ-361] C4 OpenCVGtsamPoseEstimator + Jacobian thermal hybrid
Implement the single production-default C4 PoseEstimator strategy.

AZ-358 — Marginals path: OpenCV solvePnPRansac (SOLVEPNP_IPPE) on
best-candidate inliers, PriorFactorPose3 with Jacobian-derived initial
covariance, flushed into C5's iSAM2 graph via the widened
ISam2GraphHandle.update(graph, values, None) (Option B). Posterior
covariance from compute_marginals().marginalCovariance(pose_key) with
SPD-defensive Cholesky check. Tile pixel -> ENU world conversion via
the shared WgsConverter + a configurable tile_size_px. Two spec
deviations now documented in the AZ-358 task file: PriorFactorPose3
over GenericProjectionFactorCal3DS2 (avoids unbounded landmark
variables; same Fisher information on the pose marginal) and explicit
(graph, values, timestamps) update args (aligns with C5's impl).

AZ-361 — Jacobian + thermal hybrid: per-frame dispatch on
thermal_state.thermal_throttle_active selects the cv2.projectPoints-
derived 6x6 information matrix (with ridge regularisation) as the
emitted covariance. Skips the iSAM2 factor add under throttle
(Invariant 12). Emits CovarianceDegradedWarning via warnings.warn
(never raised); paired WARN log + FDR record rate-limited per
covariance_degraded_warn_window_ns (default 60 s) via an injected
monotonic Clock. Supersedes the AZ-358 NotImplementedError stub.

Widens ISam2GraphHandle from get_pose_key only to all five C4-facing
methods (add_factor, update, compute_marginals, last_anchor_age_ms);
C5's existing ISam2GraphHandleImpl already satisfies the superset, so
no C5 source change this batch. Threads fdr_client + clock through
pose_factory composition.

Registers two new FDR payload kinds: pose.frame_done (per-call
telemetry; both success and PnpFailureError paths) and
pose.covariance_degraded (per-window throttle exposure).

Tests: 21 new (AZ-358 AC-1..11 + AZ-361 AC-1..10/12/13; AZ-361 AC-11
RMSE-ratio informational per spec, not asserted). Updates 2 existing
test files for Protocol widening and the FDR-schema round trip.

Code review verdict: PASS_WITH_WARNINGS (5 findings: Medium x2,
Low x3; none blocking). Full suite: 1958 passed, 1 unrelated
host-dependent perf failure (c12 CLI cold-start, pre-existing).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 05:01:14 +03:00

194 lines
6.8 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,
fdr_client: Any | None = None,
clock: Any | None = None,
) -> 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.
``fdr_client`` and ``clock`` are AZ-358-introduced optional
dependencies. When omitted (e.g. AZ-355 protocol-only tests that
register a fake factory) the runtime root passes ``None`` and
the concrete strategy decides how to handle it (the
``opencv_gtsam`` impl no-ops FDR enqueues + falls back to
:class:`MonotonicClock` for rate-limiting).
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 / "
"update / compute_marginals / last_anchor_age_ms?)"
)
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,
fdr_client=fdr_client,
clock=clock,
)
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