Files
gps-denied-onboard/src/gps_denied_onboard/runtime_root/vio_factory.py
T
Oleksandr Bezdieniezhnykh 94d2358c8b [AZ-918] [AZ-919] [AZ-920] [AZ-921] [AZ-922] VIO/ESKF baseline fixes
Derkachi e2e Tier-2 divergence had three stacked root causes; this
commit ships fixes for all three plus the IMU prerequisite they
depend on, plus a baseline cheirality gate for cv2.recoverPose.

AZ-918  MAVLink IMU adapters now convert raw mG/mrad-s + FRD body to
        SI m/s^2 + rad/s + FLU body via helpers.imu_units. Without
        this the ESKF receives values ~1000x too small with wrong-
        sign Y/Z and cannot function at all.

AZ-919  Composition root wires EskfNominalAltitudeProvider into the
        KLT/RANSAC strategy via the AZ-331 factory introspect path;
        OKVIS2 and VINS-Mono are unaffected.

AZ-920  KLT/RANSAC recovers metric translation via Ground Sampling
        Distance when AGL is available; otherwise falls through with
        scale_quality=direction_only/unknown (no fake scale invented).

AZ-921  VioOutput.scale_quality signal; ESKF add_vio adapts R_meas
        position block based on the flag (1e6 inflation when scale is
        direction_only/unknown to keep the filter consistent).

AZ-922  KLT/RANSAC cheirality gate rejects single-frame rotations
        beyond a config threshold (default 30 deg), catching
        cv2.recoverPose twisted-pair flips that cause immediate ESKF
        divergence on low-parallax aerial scenes.

Verification:
- Tier-1 (macOS) unit suite: 2346 passed, 0 failed.
- Tier-2 (Jetson) Derkachi e2e: divergence moves from frame 5
  (mahalanobis^2 3757) to frame 233 (mahalanobis^2 212). Remaining
  drift is open-loop attitude accumulation, not cheirality.

Follow-up tickets filed:
- AZ-923 closed as misdiagnosed: EskfNominalAltitudeProvider was
  already correct (nominal_pos.z IS the AGL when takeoff origin sits
  at ground level); the early-frame AGL near zero reflects the drone
  being stationary on the ground, not a provider bug.
- AZ-942 filed: cross-check VIO rotation against IMU preintegrator
  (consistency gate) - more physically grounded than the coarse
  AZ-922 threshold and likely required to absorb the frame-233 drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 22:28:40 +03:00

128 lines
4.6 KiB
Python

"""C1 VIO strategy composition-root factory (AZ-331).
:func:`build_vio_strategy` selects exactly one strategy by
``config.components['c1_vio'].strategy`` and respects compile-time
``BUILD_*`` gating: requesting a strategy whose flag is OFF raises
:class:`StrategyNotAvailableError` at composition time (NOT at first
frame).
Concrete strategy modules (``okvis2``, ``vins_mono``, ``klt_ransac``)
are imported lazily — a Tier-0 workstation build with
``BUILD_OKVIS2=OFF`` MUST NOT load ``c1_vio.okvis2`` (Risk-2 / I-5;
verifiable via ``sys.modules``).
"""
from __future__ import annotations
import inspect
import os
from typing import TYPE_CHECKING
from gps_denied_onboard.runtime_root.errors import StrategyNotAvailableError
if TYPE_CHECKING:
from gps_denied_onboard.components.c1_vio import (
C1VioConfig,
VioStrategy,
)
from gps_denied_onboard.components.c13_fdr import FdrClient
from gps_denied_onboard.config.schema import Config
from gps_denied_onboard.helpers.altitude_provider import AltitudeProvider
__all__ = ["build_vio_strategy"]
_STRATEGY_TO_BUILD_FLAG: dict[str, str] = {
"okvis2": "BUILD_OKVIS2",
"vins_mono": "BUILD_VINS_MONO",
"klt_ransac": "BUILD_KLT_RANSAC",
}
_STRATEGY_TO_MODULE: dict[str, tuple[str, str]] = {
"okvis2": ("gps_denied_onboard.components.c1_vio.okvis2", "Okvis2Strategy"),
"vins_mono": (
"gps_denied_onboard.components.c1_vio.vins_mono",
"VinsMonoStrategy",
),
"klt_ransac": (
"gps_denied_onboard.components.c1_vio.klt_ransac",
"KltRansacStrategy",
),
}
def _is_build_flag_on(flag_name: str) -> bool:
"""Read a compile-time ``BUILD_*`` flag from the environment.
``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``;
anything else (including unset) → ``False``. Defaults to OFF so
test environments must opt-in explicitly per strategy.
"""
raw = os.environ.get(flag_name, "")
return raw.strip().lower() in {"on", "1", "true", "yes"}
def _c1_config(config: "Config") -> "C1VioConfig":
"""Pull the registered C1 config block.
``c1_vio.__init__`` registers it on import; a missing
registration is a developer error and surfaces as ``KeyError``
rather than a silent fallback.
"""
return config.components["c1_vio"]
def build_vio_strategy(
config: "Config",
*,
fdr_client: "FdrClient",
altitude_provider: "AltitudeProvider | None" = None,
) -> "VioStrategy":
"""Construct the :class:`VioStrategy` impl selected by config.
1. Reads ``config.components['c1_vio'].strategy``.
2. Checks the matching ``BUILD_*`` flag — if OFF, raises
:class:`StrategyNotAvailableError` BEFORE any import.
3. Lazily imports the concrete strategy module.
4. Constructs and returns the strategy instance, passing
``config`` and ``fdr_client`` and (if the strategy's
``__init__`` accepts it) ``altitude_provider`` — AZ-919.
Raises :class:`StrategyNotAvailableError` when the compile-time
flag is OFF (canonical Tier-0 path) or when the concrete strategy
module has not been built yet (AZ-332 / AZ-333 / AZ-334 pending).
The ``altitude_provider`` kwarg is introspected against the
selected strategy's ``__init__`` signature so OKVIS2 / VINS-Mono
do not need to grow a parameter they do not yet consume; the
KLT/RANSAC strategy accepts it (per AZ-919 AC-3) and will use it
for GSD-based scale recovery in AZ-920.
"""
block = _c1_config(config)
strategy = block.strategy
flag_name = _STRATEGY_TO_BUILD_FLAG.get(strategy)
module_info = _STRATEGY_TO_MODULE.get(strategy)
if flag_name is None or module_info is None:
raise StrategyNotAvailableError(
f"VioStrategy {strategy!r} is not buildable in this binary."
)
if not _is_build_flag_on(flag_name):
raise StrategyNotAvailableError(
f"VioStrategy {strategy!r} requires {flag_name}=ON in this "
"binary; the flag is OFF."
)
module_name, class_name = module_info
try:
module = __import__(module_name, fromlist=[class_name])
except ModuleNotFoundError as exc:
raise StrategyNotAvailableError(
f"VioStrategy {strategy!r} is configured but its concrete impl "
f"module {module_name!r} has not been built into this binary "
"yet (AZ-332 / AZ-333 / AZ-334 pending)."
) from exc
strategy_cls = getattr(module, class_name)
kwargs: dict[str, object] = {"fdr_client": fdr_client}
if "altitude_provider" in inspect.signature(strategy_cls).parameters:
kwargs["altitude_provider"] = altitude_provider
return strategy_cls(config, **kwargs)