[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-27 22:28:40 +03:00
parent 38170b3499
commit 94d2358c8b
32 changed files with 2320 additions and 82 deletions
@@ -0,0 +1,175 @@
"""Monocular VIO metric scale recovery via Ground Sample Distance (GSD).
`cv2.recoverPose` returns a unit-length translation direction; the
absolute metric scale is unrecoverable from a single camera with no
known-size reference. AZ-920 closes that gap for the Derkachi flight
geometry — nadir-pointing camera, fixed-wing aircraft in cruise,
piecewise-planar agricultural terrain — by using the textbook
ground-plane / camera-height scale-recovery method.
The key observation: for a nadir-pointing pinhole camera at AGL ``h``
metres with focal length ``f_x`` in pixels, the GSD (ground sample
distance, m/px) is ``h / f_x``. The mean inlier optical-flow magnitude
between consecutive frames, in pixels, therefore corresponds to a
metric ground-plane displacement of ``mean_flow_px * h / f_x``. That
displacement is the magnitude of the camera-XY-plane component of the
translation; dividing by ``||t_unit_xy||`` yields the scale factor to
apply to the unit translation vector.
References (web-search-verified 2026-05-27):
- Tian et al. 2021, "A light-weight scale recovery framework based on
plane geometry", `arXiv:2101.05995 <https://arxiv.org/pdf/2101.05995v1>`_.
- Song & Chandraker, "Robust Scale Estimation in Real-Time Monocular
SFM for Autonomous Driving", CVPR 2014.
- Wikipedia, `Ground sample distance
<https://en.wikipedia.org/wiki/Ground_sample_distance>`_.
This module is intentionally a pure-Python helper: no side effects, no
logging, no clock access. The caller (``KltRansacStrategy.process_frame``)
guards the integration boundary and decides what to do when the
returned uncertainty is ``inf`` — that decision is the AZ-921 surface.
"""
from __future__ import annotations
import math
from typing import Final
import numpy as np
import numpy.typing as npt
from gps_denied_onboard._types.nav import ScaleQuality
__all__ = [
"INFINITE_SCALE_UNCERTAINTY_M",
"RELATIVE_DISPLACEMENT_NOISE",
"ScaleRecoveryResult",
"recover_scale",
]
# 10% relative-displacement noise budget covering GSD focal-length
# error (~3% per camera_info.md), AGL bias from the ESKF integrator,
# and inlier-flow-mean residual scatter. Pin until AZ-920 lands a real
# Jetson run and we can measure the residual.
RELATIVE_DISPLACEMENT_NOISE: Final[float] = 0.10
# Sentinel returned when scale is not recoverable in the current frame
# (AGL unknown, near-pure-vertical motion, zero inlier flow). The
# caller treats this as "do not trust translation magnitude".
INFINITE_SCALE_UNCERTAINTY_M: Final[float] = math.inf
# AZ-921 — below this horizontal-component magnitude the camera is
# moving essentially along its optical axis (climb/descent for a nadir
# cam), which is degenerate for the GSD MAGNITUDE recovery — but the
# DIRECTION of t_unit is still meaningful, so this case routes to
# scale_quality="direction_only" rather than "unknown".
_MIN_T_HORIZONTAL_MAGNITUDE: Final[float] = 0.05
# AZ-921 — below this mean inlier flow magnitude the strategy cannot
# distinguish "stationary aircraft" from "scene-locked features that
# happen not to move" (hover, stale features). Routes to
# scale_quality="unknown" because the geometry is too degenerate to
# trust either the magnitude or the direction of t_unit.
_MIN_MEAN_FLOW_PX: Final[float] = 0.5
class ScaleRecoveryResult:
"""Lightweight return tuple — keeps three named values together.
Not a frozen dataclass to avoid the import-cost overhead on the
KLT/RANSAC hot path (called every frame).
"""
__slots__ = ("quality", "scale_factor", "scale_uncertainty_m")
def __init__(
self,
scale_factor: float,
scale_uncertainty_m: float,
quality: ScaleQuality,
) -> None:
self.scale_factor = scale_factor
self.scale_uncertainty_m = scale_uncertainty_m
self.quality = quality
def is_recoverable(self) -> bool:
return math.isfinite(self.scale_uncertainty_m)
def __repr__(self) -> str:
return (
f"ScaleRecoveryResult(scale_factor={self.scale_factor!r}, "
f"scale_uncertainty_m={self.scale_uncertainty_m!r}, "
f"quality={self.quality!r})"
)
def recover_scale(
*,
t_unit: npt.NDArray[np.float64],
pts_prev: npt.NDArray[np.float64],
pts_curr: npt.NDArray[np.float64],
intrinsics_3x3: npt.NDArray[np.float64],
agl_m: float | None,
) -> ScaleRecoveryResult:
"""Compute the metric scale factor for a monocular unit translation.
Args:
t_unit: Unit-length 3-vector from ``cv2.recoverPose`` (camera
coords; OpenCV convention is X-right, Y-down, Z-forward).
pts_prev: Nx2 inlier feature pixel coordinates in the previous
frame.
pts_curr: Nx2 inlier feature pixel coordinates in the current
frame.
intrinsics_3x3: Camera intrinsic matrix; the function uses
``f_x = K[0, 0]`` as the nadir-pinhole focal length in
pixels (``f_y`` is assumed equal — the Derkachi factory
calibration matches this).
agl_m: Above-ground-level height in metres, or ``None`` if the
AltitudeProvider could not resolve it.
Returns:
:class:`ScaleRecoveryResult`. ``scale_uncertainty_m`` is
:data:`INFINITE_SCALE_UNCERTAINTY_M` when scale cannot be
recovered (AGL unknown, zero focal length, degenerate flow,
near-pure-vertical motion). In every degenerate case the
``scale_factor`` is ``0.0`` so multiplying a unit translation
yields a zero translation — safe default that lets the ESKF's
own bias prior absorb the missing constraint.
Notes:
Geometry assumption: nadir-pointing pinhole camera. The
derivation is in the module docstring. When the assumption
breaks (banked turn, gimbal not nadir, mountainous terrain),
AZ-921 will gate this output via a ``scale_quality`` signal.
"""
if agl_m is None or agl_m <= 0.0:
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "unknown")
fx = float(intrinsics_3x3[0, 0])
if fx <= 0.0 or not math.isfinite(fx):
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "unknown")
if pts_prev.shape[0] == 0 or pts_curr.shape[0] == 0:
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "unknown")
if pts_prev.shape != pts_curr.shape:
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "unknown")
flow = pts_curr - pts_prev
flow_magnitudes_px = np.linalg.norm(flow, axis=1)
mean_flow_px = float(np.mean(flow_magnitudes_px))
if not math.isfinite(mean_flow_px) or mean_flow_px < _MIN_MEAN_FLOW_PX:
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "unknown")
t_horizontal = float(np.hypot(t_unit[0], t_unit[1]))
if not math.isfinite(t_horizontal) or t_horizontal < _MIN_T_HORIZONTAL_MAGNITUDE:
return ScaleRecoveryResult(0.0, INFINITE_SCALE_UNCERTAINTY_M, "direction_only")
# GSD m/px = AGL / f_x for nadir pinhole.
gsd_m_per_px = agl_m / fx
displacement_m = mean_flow_px * gsd_m_per_px
scale_factor = displacement_m / t_horizontal
scale_uncertainty_m = displacement_m * RELATIVE_DISPLACEMENT_NOISE
return ScaleRecoveryResult(scale_factor, scale_uncertainty_m, "metric")
@@ -28,6 +28,7 @@ min features for pose, per-frame debug log). Only consulted when
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Final
@@ -214,6 +215,15 @@ class KltRansacConfig:
internal RANSAC; default 1.0 px in normalised image coordinates
(OpenCV's documented default for forward-looking cameras).
``max_frame_rotation_rad`` is the AZ-922 cheirality gate
threshold — pose-recovery results whose rotation angle exceeds
this value are rejected as the essential-matrix twisted-pair
ambiguity (``cv2.recoverPose`` can return a 180° rotation when
parallax is low). Default 0.524 rad (30°) — a generous upper bound
for any physically plausible single-frame body rotation in
RESTRICT-UAV-3 within the 33 ms / 30 fps frame budget. Must be
in (0, π].
``per_frame_debug_log`` enables a DEBUG log line per
``process_frame`` — OFF by default (would flood at 3 Hz steady-state).
"""
@@ -224,6 +234,7 @@ class KltRansacConfig:
min_features_for_pose: int = 30
ransac_inlier_ratio: float = 0.5
essential_matrix_ransac_threshold_px: float = 1.0
max_frame_rotation_rad: float = math.radians(30.0)
per_frame_debug_log: bool = False
def __post_init__(self) -> None:
@@ -257,6 +268,11 @@ class KltRansacConfig:
"KltRansacConfig.essential_matrix_ransac_threshold_px must be "
f"> 0; got {self.essential_matrix_ransac_threshold_px}"
)
if not (0.0 < self.max_frame_rotation_rad <= math.pi):
raise ConfigError(
"KltRansacConfig.max_frame_rotation_rad must be in (0, pi]; "
f"got {self.max_frame_rotation_rad}"
)
@dataclass(frozen=True)
@@ -74,6 +74,7 @@ Risk mitigations (see task spec for full text):
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Final, Literal
import cv2
@@ -82,6 +83,7 @@ import numpy as np
from gps_denied_onboard._types.nav import (
FeatureQuality,
ImuBias,
ScaleQuality,
VioHealth,
VioOutput,
VioState,
@@ -92,6 +94,10 @@ from gps_denied_onboard.components.c1_vio._facade_spine import (
bias_norm,
se3_from_4x4,
)
from gps_denied_onboard.components.c1_vio._gsd_scale import (
ScaleRecoveryResult,
recover_scale,
)
from gps_denied_onboard.components.c1_vio.errors import (
VioFatalError,
VioInitializingError,
@@ -117,6 +123,7 @@ if TYPE_CHECKING:
from gps_denied_onboard.components.c1_vio.config import KltRansacConfig
from gps_denied_onboard.config import Config
from gps_denied_onboard.fdr_client.client import FdrClient
from gps_denied_onboard.helpers.altitude_provider import AltitudeProvider
__all__ = ["KltRansacStrategy"]
@@ -134,6 +141,13 @@ _ESSENTIAL_MATRIX_DOF: Final[int] = 5
# (relative pose is identity anyway). Documented limit, not derived.
_INIT_STATE_COVARIANCE_SCALAR: Final[float] = 10.0
# AZ-920: floor on the per-axis position variance for scale-recovered
# translations. Even with perfect inlier flow the GSD has a
# ~3 % focal-length error (see flight_derkachi/camera_info.md) and
# the AGL has ESKF integrator bias, so a sub-millimetre cov is
# implausible. 1 cm sigma per axis is the smallest defensible value.
_MIN_POSITION_VARIANCE_M2: Final[float] = 1.0e-4
def _grayscale(image: npt.NDArray[Any]) -> npt.NDArray[Any]:
"""Coerce a NavCameraFrame image to OpenCV's expected 2D ``uint8``.
@@ -168,6 +182,21 @@ def _grayscale(image: npt.NDArray[Any]) -> npt.NDArray[Any]:
)
def _rotation_angle_rad(R: np.ndarray) -> float:
"""Axis-angle magnitude of a 3x3 rotation matrix.
AZ-922 cheirality gate primitive. Uses ``θ = arccos((trace(R) - 1) / 2)``;
the ``arccos`` argument is clipped to ``[-1, +1]`` to defend against
floating-point drift on near-identity rotations (where ``trace`` can
exceed 3.0 by ~1e-15) and on the 180° antipode (where it can dip
below -1.0 by the same amount). The returned angle is always in
``[0, π]``.
"""
trace = float(np.trace(R[:3, :3]))
cos_theta = max(-1.0, min(1.0, (trace - 1.0) / 2.0))
return float(math.acos(cos_theta))
def _intrinsics_3x3(calibration: CameraCalibration) -> np.ndarray:
"""Pull the 3x3 intrinsics matrix from a CameraCalibration DTO.
@@ -208,6 +237,7 @@ class KltRansacStrategy(FacadeSpine):
*,
fdr_client: FdrClient,
clock: Clock | None = None,
altitude_provider: AltitudeProvider | None = None,
) -> None:
c1_block = config.components["c1_vio"]
if c1_block.strategy != _STRATEGY_LABEL:
@@ -227,6 +257,12 @@ class KltRansacStrategy(FacadeSpine):
self._feature_threshold: int = self._cfg.min_features_for_pose
self._producer_id: str = _PRODUCER_ID
self._strategy_label: str = _STRATEGY_LABEL
# AZ-919: plumbing only — stored on the instance but not yet consumed.
# The scale-recovery math that consumes this lands in AZ-920;
# the degraded-mode signal when the provider yields ``None``
# lands in AZ-921. Default ``None`` preserves AC-3 bug-for-bug
# compatibility with strategies that have not been re-wired.
self._altitude_provider: AltitudeProvider | None = altitude_provider
# Per-frame state.
self._prev_gray: np.ndarray | None = None
@@ -383,20 +419,70 @@ class KltRansacStrategy(FacadeSpine):
reason="recover_pose_no_solution",
)
# 7. Build SE(3) from (R, t).
# 6.5 AZ-922 — cheirality gate. ``cv2.recoverPose`` can return
# a 180°-flipped rotation (essential-matrix twisted-pair
# ambiguity) under low-parallax conditions. Reject any
# single-frame rotation that exceeds the RESTRICT-UAV-3
# angular-velocity envelope. Routes through the same
# _pose_recovery_failed path as any other failed-pose case so
# AC-7 (LOST after N consecutive failures) is honoured.
R_3x3 = np.asarray(R, dtype=np.float64)
rotation_angle_rad = _rotation_angle_rad(R_3x3)
if rotation_angle_rad > self._cfg.max_frame_rotation_rad:
return self._pose_recovery_failed(
frame_id_str,
emitted_at_ns,
prior_feature_count=prior_feature_count,
reason=(
f"implausible_rotation_angle: "
f"{math.degrees(rotation_angle_rad):.1f}deg > "
f"{math.degrees(self._cfg.max_frame_rotation_rad):.1f}deg threshold"
),
)
# 7. AZ-920 — recover metric scale for the otherwise-unit
# translation. Operates on the final inlier set (AZ-282 +
# findEssentialMat's internal RANSAC). Only fires when the
# AltitudeProvider is wired by the AZ-919 composition root;
# when it is None (unit-test construction, legacy callers) the
# strategy preserves pre-AZ-920 behaviour bug-for-bug
# (AZ-919 AC-3 compatibility).
t_unit = np.asarray(t, dtype=np.float64).flatten()
t_metric = t_unit
scale_result: ScaleRecoveryResult | None = None
if self._altitude_provider is not None:
if em_mask is not None:
em_mask_flat = np.asarray(em_mask).flatten().astype(bool)
final_pts_prev = pts_prev[em_mask_flat]
final_pts_curr = pts_curr[em_mask_flat]
else:
final_pts_prev = pts_prev
final_pts_curr = pts_curr
agl_m = self._altitude_provider.agl_m(emitted_at_ns)
scale_result = recover_scale(
t_unit=t_unit,
pts_prev=final_pts_prev,
pts_curr=final_pts_curr,
intrinsics_3x3=K,
agl_m=agl_m,
)
if scale_result.is_recoverable():
t_metric = scale_result.scale_factor * t_unit
# 8. Build SE(3) from (R, t_metric).
pose_4x4 = np.eye(4, dtype=np.float64)
pose_4x4[:3, :3] = np.asarray(R, dtype=np.float64)
pose_4x4[:3, 3] = np.asarray(t, dtype=np.float64).flatten()
pose_4x4[:3, 3] = t_metric
pose = se3_from_4x4(pose_4x4)
# 8. Final inlier count for state classification + covariance.
# 9. Final inlier count for state classification + covariance.
final_inlier_count = (
int(np.count_nonzero(em_mask)) if em_mask is not None else ransac_result.inlier_count
)
if final_inlier_count < _ESSENTIAL_MATRIX_DOF:
final_inlier_count = ransac_result.inlier_count
# 9. Estimate covariance from the AZ-282 median residual +
# 10. Estimate covariance from the AZ-282 median residual +
# inlier-count penalty. Honest-covariance invariant (AC-9): no
# client-side floor; the formula is residual_var / DOF + small
# inlier-count term so the cov grows monotonically as inliers
@@ -406,6 +492,24 @@ class KltRansacStrategy(FacadeSpine):
inlier_count=final_inlier_count,
)
# 11. AZ-920/AZ-921 — when scale_quality == "metric", replace the
# position block of cov with the metric variance from GSD scale
# recovery so consumers reading pose_covariance_6x6 directly get
# honest m² units. When scale_quality is "direction_only" or
# "unknown" the cov stays in the geometry-natural units; the
# ESKF (AZ-921 add_vio) inflates its own R_meas based on the
# scale_quality flag, so it does not need the cov to encode the
# "do not trust me" message.
scale_quality: ScaleQuality = (
scale_result.quality if scale_result is not None else "unknown"
)
if scale_result is not None and scale_quality == "metric":
pos_var = max(
float(scale_result.scale_uncertainty_m) ** 2,
_MIN_POSITION_VARIANCE_M2,
)
cov[0:3, 0:3] = np.eye(3, dtype=np.float64) * pos_var
# 10. Build VioOutput.
fq = FeatureQuality(
tracked=int(final_inlier_count),
@@ -423,6 +527,7 @@ class KltRansacStrategy(FacadeSpine):
imu_bias=self._latest_bias,
feature_quality=fq,
emitted_at_ns=emitted_at_ns,
scale_quality=scale_quality,
)
# 11. Success path — reset lost counter, classify state.
@@ -56,7 +56,6 @@ import numpy as np
from numpy.linalg import LinAlgError
from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard._types.state import (
EstimatorHealth,
EstimatorOutput,
@@ -64,22 +63,23 @@ from gps_denied_onboard._types.state import (
PoseSourceLabel,
Quat,
)
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard.components.c5_state._fallback_watcher import (
FallbackEngagementCallback,
FallbackRecoveryCallback,
FallbackSubscription,
FallbackWatcher,
)
from gps_denied_onboard.components.c5_state._source_label_sm import (
RejectionCallback,
RejectionSubscription,
SourceLabelStateMachine,
)
from gps_denied_onboard.components.c5_state._orthorectifier import (
MidFlightTileWriter,
Orthorectifier,
OrthorectifierThresholds,
)
from gps_denied_onboard.components.c5_state._source_label_sm import (
RejectionCallback,
RejectionSubscription,
SourceLabelStateMachine,
)
from gps_denied_onboard.components.c5_state.config import C5StateConfig
from gps_denied_onboard.components.c5_state.errors import (
EstimatorAlreadyStartedError,
@@ -139,6 +139,17 @@ _PROC_SIGMA_BG: Final[float] = 0.0001
# Fall-back measurement noise when the input DTO has no covariance.
_DEFAULT_MEAS_SIGMA: Final[float] = 0.1
# AZ-921 — translation-block R_meas adaptation when the VIO scale signal
# is degraded. The unit-length translation from cv2.recoverPose carries
# direction information but no magnitude; we trust the direction at the
# DIRECTION-ONLY level so the rotation update can still constrain the
# state, then a follow-up scale-recovery event will reduce the position
# uncertainty. UNKNOWN is the "effectively skip the translation update"
# variance — large enough that the Kalman gain block is essentially
# zero, while still finite so the Mahalanobis innovation gate computes.
_DIRECTION_ONLY_TRANSLATION_SIGMA_M: Final[float] = 8.0
_UNKNOWN_TRANSLATION_SIGMA_M: Final[float] = 1000.0
# Filter divergence threshold (AC-9): Mahalanobis ``r^T S^{-1} r`` >
# (10 sigma)^2 = 100 triggers EstimatorFatalError + AC-5.2 fallback.
# The spec wording is "innovation exceeds 10x the measurement-covariance
@@ -243,8 +254,8 @@ class EskfStateEstimator(StateEstimator):
# historically did NOT cache the full DTO so we keep a
# dedicated field rather than abuse ``_prev_vio_*``.
self._orthorectifier: Orthorectifier | None = None
self._latest_nav_frame: "NavCameraFrame | None" = None
self._latest_vio: "VioOutput | None" = None
self._latest_nav_frame: NavCameraFrame | None = None
self._latest_vio: VioOutput | None = None
self._log.debug(
"c5.state.eskf_initialised",
@@ -296,7 +307,7 @@ class EskfStateEstimator(StateEstimator):
"""
self._orthorectifier = orthorectifier
def set_latest_nav_frame(self, frame: "NavCameraFrame") -> None:
def set_latest_nav_frame(self, frame: NavCameraFrame) -> None:
"""AZ-389 — buffer the most recent nav-camera frame.
Quiet no-op when no orthorectifier is attached. See
@@ -541,6 +552,12 @@ class EskfStateEstimator(StateEstimator):
H[0:3, _IDX_POS] = np.eye(3)
H[3:6, _IDX_ROT] = prev_R # rotate body-frame perturbation back to world
R_meas = _measurement_noise(vio.pose_covariance_6x6)
# AZ-921 — VIO scale-quality gating on the translation block of
# R_meas. The rotation block is left as the VIO emitted it
# regardless of scale_quality; only the magnitude of t is
# affected by monocular scale ambiguity, not its direction in
# body frame or the rotation matrix.
R_meas = _adapt_vio_r_meas(R_meas, vio.scale_quality)
try:
self._kalman_update(H, residual, R_meas, source="vio")
@@ -991,7 +1008,7 @@ def create(
wgs_converter: Any,
fdr_client: Any,
mid_flight_tile_writer: MidFlightTileWriter | None = None,
camera_calibration: "CameraCalibration | None" = None,
camera_calibration: CameraCalibration | None = None,
flight_id: str | None = None,
companion_id: str | None = None,
) -> tuple[StateEstimator, Any]:
@@ -1086,6 +1103,30 @@ def _measurement_noise(covariance: Any | None) -> np.ndarray:
return cov
def _adapt_vio_r_meas(r_meas: np.ndarray, scale_quality: str) -> np.ndarray:
"""AZ-921 — translation-block adaptation per VIO scale-quality.
When the VIO strategy reports it could not recover metric scale,
the translation block of R_meas is overwritten so the Kalman gain
in that block goes to (near) zero, leaving the rotation block
free to constrain the state. The Mahalanobis innovation gate
stays at 100 (10 sigma); large translation R_meas keeps the test
finite and well below the threshold even on large residuals.
The function does not mutate the input array.
"""
if scale_quality == "metric":
return r_meas
if scale_quality == "direction_only":
translation_variance = _DIRECTION_ONLY_TRANSLATION_SIGMA_M**2
else:
# "unknown" and any defensive default
translation_variance = _UNKNOWN_TRANSLATION_SIGMA_M**2
adapted = r_meas.copy()
adapted[0:3, 0:3] = np.eye(3, dtype=np.float64) * translation_variance
return adapted
def _skew(v: np.ndarray) -> np.ndarray:
return np.array(
[
@@ -36,6 +36,7 @@ from gps_denied_onboard._types.geo import LatLonAlt
from gps_denied_onboard.clock.wall_clock import WallClock
from gps_denied_onboard.components.c8_fc_adapter._subscription import SubscriptionBus
from gps_denied_onboard.components.c8_fc_adapter._telemetry_rings import TelemetryRing
from gps_denied_onboard.helpers.imu_units import mavlink_imu_to_si_flu
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
@@ -194,17 +195,26 @@ class PymavlinkInboundDecoder:
# Per-type handlers
def _handle_imu(self, msg: Any) -> bool:
# pymavlink RAW_IMU exposes raw signed-16 ticks; we forward as-is.
accel = (float(msg.xacc), float(msg.yacc), float(msg.zacc))
gyro = (float(msg.xgyro), float(msg.ygyro), float(msg.zgyro))
# RAW_IMU / SCALED_IMU2 wire format is mG accel + mrad/s gyro in
# FRD body; convert to SI/FLU at the boundary (AZ-918) so
# downstream consumers see the contract ImuTelemetrySample
# advertises.
accel_si_flu, gyro_si_flu = mavlink_imu_to_si_flu(
xacc=float(msg.xacc),
yacc=float(msg.yacc),
zacc=float(msg.zacc),
xgyro=float(msg.xgyro),
ygyro=float(msg.ygyro),
zgyro=float(msg.zgyro),
)
# `time_usec` is FC-side; we override with monotonic_ns at decode boundary
# for the FdrTelemetryFrame.received_at; the payload's ts_ns is the
# FC sensor stamp (epoch us -> ns).
sensor_ts_ns = int(msg.time_usec) * 1000
payload = ImuTelemetrySample(
ts_ns=sensor_ts_ns,
accel_xyz=accel,
gyro_xyz=gyro,
accel_xyz=accel_si_flu,
gyro_xyz=gyro_si_flu,
)
return self._dispatch(TelemetryKind.IMU_SAMPLE, payload, ring=self.imu_ring)
@@ -81,6 +81,7 @@ from gps_denied_onboard.components.c8_fc_adapter.errors import (
)
from gps_denied_onboard.components.c8_fc_adapter.interface import MavlinkTransport
from gps_denied_onboard.fdr_client.records import FdrRecord
from gps_denied_onboard.helpers.imu_units import mavlink_imu_to_si_flu
from gps_denied_onboard.helpers.iso_timestamps import iso_ts_now
from gps_denied_onboard.logging import get_logger
@@ -716,20 +717,18 @@ class TlogReplayFcAdapter:
def _handle_imu(self, msg: Any) -> bool:
sensor_ts_ns = int(getattr(msg, "time_usec", 0)) * 1000
accel = (
float(msg.xacc),
float(msg.yacc),
float(msg.zacc),
)
gyro = (
float(msg.xgyro),
float(msg.ygyro),
float(msg.zgyro),
accel_si_flu, gyro_si_flu = mavlink_imu_to_si_flu(
xacc=float(msg.xacc),
yacc=float(msg.yacc),
zacc=float(msg.zacc),
xgyro=float(msg.xgyro),
ygyro=float(msg.ygyro),
zgyro=float(msg.zgyro),
)
payload = ImuTelemetrySample(
ts_ns=sensor_ts_ns,
accel_xyz=accel,
gyro_xyz=gyro,
accel_xyz=accel_si_flu,
gyro_xyz=gyro_si_flu,
)
return self._dispatch(TelemetryKind.IMU_SAMPLE, payload, msg=msg)