mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 10:31:13 +00:00
[AZ-623] [AZ-625] Phase E: c282_ransac + c5 helpers; split handle work
Wire 4 stateless / cached helpers into airborne_bootstrap.build_pre_constructed: c282_ransac_filter, c5_imu_preintegrator (cached on calibration path), c5_se3_utils (helpers.se3_utils module as namespace handle), c5_wgs_converter. The original AZ-623 5th deliverable (c5_isam2_graph_handle) hit an unresolvable construction-order conflict between c4_pose (consumes the handle) and c5_state (creates it inside build_state_estimator's tuple return) under the umbrella's "MUST NOT touch any per-component factory signature" constraint. Per AZ-623 spec's escalation gate, scope was split: AZ-625 captures the handle ordering work; AZ-624 dependency edge updated to require both. Tests: tests/unit/runtime_root/test_az623_pre_constructed_phase_e.py adds 7 tests covering AC-623.1..3 (4 new keys + correct types, IMU preintegrator caching, operator-actionable error messages for empty / unreadable / malformed calibration paths). Autouse stubs added to test_az619/620/621/622 so prior phase tests remain isolated from new builders. Quality gates: ruff format clean, ruff lint clean, 24/24 phase tests pass, 247/247 runtime_root + c5_state regression suite passes. Code review verdict PASS_WITH_WARNINGS (3 Low findings; full report in _docs/03_implementation/reviews/batch_94_review.md). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,12 +59,8 @@ def _stub_c6_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Config() used below would hit KeyError inside storage_factory's
|
||||
# config.components["c6_tile_cache"] lookup. Sentinel objects are
|
||||
# opaque on purpose — the AZ-619 assertions never inspect them.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap, "_build_c6_descriptor_index", lambda _config: object()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap, "_build_c6_tile_store", lambda _config: object()
|
||||
)
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c6_descriptor_index", lambda _config: object())
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c6_tile_store", lambda _config: object())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -75,9 +71,7 @@ def _stub_c7_inference_builder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# config.components["c7_inference"] lookup, AND the airborne BUILD_*
|
||||
# flags are typically unset in the test env. The sentinel is opaque
|
||||
# on purpose — AZ-619 assertions never inspect it.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap, "_build_c7_inference", lambda _config: object()
|
||||
)
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c7_inference", lambda _config: object())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -93,9 +87,22 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"_build_c3_lightglue_runtime",
|
||||
lambda _config, *, inference_runtime: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap, "_build_c3_feature_extractor", lambda _config: object()
|
||||
)
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c3_feature_extractor", lambda _config: object())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so the AZ-619
|
||||
# tests stay focused on Phase A. Without this the bare Config() below
|
||||
# would hit _build_c5_imu_preintegrator's
|
||||
# config.runtime.camera_calibration_path empty-check and raise
|
||||
# AirborneBootstrapError before the AC-619 keys could be asserted.
|
||||
# Sentinels are opaque on purpose — AZ-619 assertions never inspect
|
||||
# them.
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c282_ransac_filter", lambda _config: object())
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c5_imu_preintegrator", lambda _config: object())
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c5_se3_utils", lambda _config: object())
|
||||
monkeypatch.setattr(airborne_bootstrap, "_build_c5_wgs_converter", lambda _config: object())
|
||||
|
||||
|
||||
def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None:
|
||||
|
||||
@@ -95,6 +95,37 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-620
|
||||
# tests stay focused on the Phase B contract. Without this the configs
|
||||
# used below would hit _build_c5_imu_preintegrator's
|
||||
# config.runtime.camera_calibration_path empty-check and raise
|
||||
# AirborneBootstrapError before the AC-620 keys could be asserted.
|
||||
# MagicMock sentinels are opaque on purpose — AZ-620 assertions
|
||||
# never inspect them.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c282_ransac_filter",
|
||||
lambda _config: MagicMock(name="RansacFilter"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_imu_preintegrator",
|
||||
lambda _config: MagicMock(name="ImuPreintegrator"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_se3_utils",
|
||||
lambda _config: MagicMock(name="Se3Utils"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -98,6 +98,37 @@ def _stub_c3_matcher_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-621
|
||||
# tests stay focused on the Phase C contract. Without this the configs
|
||||
# used below would hit _build_c5_imu_preintegrator's
|
||||
# config.runtime.camera_calibration_path empty-check and raise
|
||||
# AirborneBootstrapError before the AC-621 keys could be asserted.
|
||||
# Sentinels are opaque on purpose — AZ-621 assertions never inspect
|
||||
# them.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c282_ransac_filter",
|
||||
lambda _config: MagicMock(name="RansacFilter"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_imu_preintegrator",
|
||||
lambda _config: MagicMock(name="ImuPreintegrator"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_se3_utils",
|
||||
lambda _config: MagicMock(name="Se3Utils"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_621_1_adds_c7_inference(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub build_inference_runtime to a sentinel so we can
|
||||
# assert wiring without standing up real GPU/TensorRT/PyTorch.
|
||||
@@ -133,9 +164,7 @@ def test_ac_621_2_both_build_flags_off_with_configured_consumer_raises_named_err
|
||||
"build_inference_runtime",
|
||||
_raise_no_c7_runtime_available,
|
||||
)
|
||||
config = Config.with_blocks(
|
||||
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
|
||||
)
|
||||
config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
|
||||
@@ -104,6 +104,35 @@ def _stub_c6_and_c7_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_c5_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-623 Phase E c5 / RANSAC builders so AZ-622
|
||||
# tests stay focused on the Phase D contract. Without this the configs
|
||||
# used below would hit _build_c5_imu_preintegrator's
|
||||
# config.runtime.camera_calibration_path empty-check and raise
|
||||
# AirborneBootstrapError before the AC-622 keys could be asserted.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c282_ransac_filter",
|
||||
lambda _config: MagicMock(name="RansacFilter"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_imu_preintegrator",
|
||||
lambda _config: MagicMock(name="ImuPreintegrator"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_se3_utils",
|
||||
lambda _config: MagicMock(name="Se3Utils"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c5_wgs_converter",
|
||||
lambda _config: MagicMock(name="WgsConverter"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -130,10 +159,7 @@ def test_ac_622_1_adds_c3_lightglue_runtime_and_c3_feature_extractor(
|
||||
assert "c3_lightglue_runtime" in pre_constructed
|
||||
assert "c3_feature_extractor" in pre_constructed
|
||||
assert isinstance(pre_constructed["c3_lightglue_runtime"], LightGlueRuntime)
|
||||
assert (
|
||||
pre_constructed["c3_lightglue_runtime"].descriptor_dim()
|
||||
== engine_handle.descriptor_dim
|
||||
)
|
||||
assert pre_constructed["c3_lightglue_runtime"].descriptor_dim() == engine_handle.descriptor_dim
|
||||
assert isinstance(pre_constructed["c3_feature_extractor"], FeatureExtractor)
|
||||
assert isinstance(pre_constructed["c3_feature_extractor"], OpenCvOrbExtractor)
|
||||
assert {
|
||||
@@ -154,9 +180,7 @@ def test_ac_622_2_build_flag_off_with_configured_strategy_raises_named_error(
|
||||
# fire BEFORE _load_lightglue_engine_handle is consulted, so we don't
|
||||
# need to stub the loader for this branch.
|
||||
monkeypatch.delenv("BUILD_MATCHER_DISK_LIGHTGLUE", raising=False)
|
||||
config = Config.with_blocks(
|
||||
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
|
||||
)
|
||||
config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
@@ -168,9 +192,7 @@ def test_ac_622_2_build_flag_off_with_configured_strategy_raises_named_error(
|
||||
# AC-622.2 + AZ-618 NFR "operator-facing error contract").
|
||||
assert "c3_lightglue_runtime" in message
|
||||
expected_flag = C3_MATCHER_BUILD_FLAGS["disk_lightglue"]
|
||||
assert expected_flag in message, (
|
||||
f"{expected_flag!r} missing from error: {message!r}"
|
||||
)
|
||||
assert expected_flag in message, f"{expected_flag!r} missing from error: {message!r}"
|
||||
assert "c3_matcher" in message
|
||||
# The flag-OFF branch raises directly — there is no upstream cause to
|
||||
# preserve (cause-chain preservation is exercised in
|
||||
@@ -184,9 +206,7 @@ def test_ac_622_2_build_flag_off_with_aliked_strategy_names_aliked_flag(
|
||||
"""Per-strategy flag specificity: aliked_lightglue surfaces ALIKED's flag."""
|
||||
# Arrange: ALIKED strategy with its own gating flag OFF.
|
||||
monkeypatch.delenv("BUILD_MATCHER_ALIKED_LIGHTGLUE", raising=False)
|
||||
config = Config.with_blocks(
|
||||
c3_matcher=_C3MatcherBlock(strategy="aliked_lightglue")
|
||||
)
|
||||
config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="aliked_lightglue"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
@@ -245,9 +265,7 @@ def test_ac_622_2_lightglue_engine_load_failure_wraps_runtime_error(
|
||||
"_load_lightglue_engine_handle",
|
||||
_raise_engine_load_failure,
|
||||
)
|
||||
config = Config.with_blocks(
|
||||
c3_matcher=_C3MatcherBlock(strategy="disk_lightglue")
|
||||
)
|
||||
config = Config.with_blocks(c3_matcher=_C3MatcherBlock(strategy="disk_lightglue"))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as excinfo:
|
||||
@@ -281,9 +299,7 @@ def test_lightglue_runtime_uses_c7_inference_from_pre_constructed(
|
||||
captured["inference_runtime"] = inference_runtime
|
||||
return _make_engine_handle_mock(descriptor_dim=128)
|
||||
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap, "_load_lightglue_engine_handle", _capture_loader
|
||||
)
|
||||
monkeypatch.setattr(airborne_bootstrap, "_load_lightglue_engine_handle", _capture_loader)
|
||||
config = Config()
|
||||
|
||||
# Act
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
"""AZ-623 — Phase E of AZ-618: ``build_pre_constructed`` seeds c282_ransac_filter + 3 c5 helpers.
|
||||
|
||||
Verifies the contract at
|
||||
``_docs/02_tasks/todo/AZ-623_pre_constructed_phase_e_ransac_c5_helpers.md``
|
||||
(scope-narrowed 2026-05-19; ``c5_isam2_graph_handle`` deferred to AZ-625):
|
||||
|
||||
* AC-623.1: ``build_pre_constructed(default_config)`` adds
|
||||
``c282_ransac_filter`` (a :class:`RansacFilter` instance),
|
||||
``c5_imu_preintegrator`` (an :class:`ImuPreintegrator` instance),
|
||||
``c5_se3_utils`` (the :mod:`gps_denied_onboard.helpers.se3_utils`
|
||||
module), and ``c5_wgs_converter`` (a :class:`WgsConverter` instance)
|
||||
on top of AZ-619..AZ-622.
|
||||
* AC-623.2: invoking twice in the same process produces dicts where
|
||||
``c5_imu_preintegrator`` is the SAME instance both calls (cached by
|
||||
``config.runtime.camera_calibration_path``); the 3 stateless helpers
|
||||
may be either fresh or cached.
|
||||
* AC-623.3: when ``config.runtime.camera_calibration_path`` is empty or
|
||||
unreadable, ``_build_c5_imu_preintegrator`` raises
|
||||
:class:`AirborneBootstrapError` whose message names the missing input
|
||||
AND the consuming component slug ``c5_state``.
|
||||
|
||||
AC-623.4 (this file exists with the above tests) is satisfied by the
|
||||
existence of this module.
|
||||
|
||||
The tests stub the heavy ``_load_camera_calibration`` seam so they
|
||||
exercise the helper-wiring contract without standing up an on-disk
|
||||
calibration JSON. The upstream AZ-619..AZ-622 builders are stubbed at
|
||||
the airborne_bootstrap module boundary, mirroring the prior phase
|
||||
pattern (see :mod:`tests.unit.runtime_root.test_az622_pre_constructed_phase_d`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.calibration import CameraCalibration
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.fdr_client import client as fdr_client_module
|
||||
from gps_denied_onboard.helpers import se3_utils as se3_utils_module
|
||||
from gps_denied_onboard.helpers.imu_preintegrator import ImuPreintegrator
|
||||
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
|
||||
from gps_denied_onboard.helpers.wgs_converter import WgsConverter
|
||||
from gps_denied_onboard.runtime_root import airborne_bootstrap
|
||||
from gps_denied_onboard.runtime_root.airborne_bootstrap import (
|
||||
AirborneBootstrapError,
|
||||
build_pre_constructed,
|
||||
clear_imu_preintegrator_cache,
|
||||
)
|
||||
|
||||
|
||||
def _config_with_calibration_path(path: str) -> Config:
|
||||
"""Return a fresh ``Config`` whose ``runtime.camera_calibration_path`` is set.
|
||||
|
||||
``RuntimeConfig`` is a frozen dataclass; mutate via
|
||||
:func:`dataclasses.replace` rather than ``object.__setattr__``.
|
||||
"""
|
||||
base = Config()
|
||||
runtime = dataclasses.replace(base.runtime, camera_calibration_path=path)
|
||||
return dataclasses.replace(base, runtime=runtime)
|
||||
|
||||
|
||||
def _make_calibration(camera_id: str = "az623-test-camera") -> CameraCalibration:
|
||||
"""Sentinel CameraCalibration matching the C5 estimator's expected shape.
|
||||
|
||||
The bias / sample accumulator inside :class:`ImuPreintegrator` only
|
||||
needs intrinsics-shape inputs to be present; AZ-623 tests do not
|
||||
integrate samples, so the contents are irrelevant beyond passing
|
||||
:func:`make_imu_preintegrator`'s own internal validators.
|
||||
"""
|
||||
return CameraCalibration(
|
||||
camera_id=camera_id,
|
||||
intrinsics_3x3=np.eye(3, dtype=np.float64),
|
||||
distortion=np.zeros(5, dtype=np.float64),
|
||||
body_to_camera_se3=np.eye(4, dtype=np.float64),
|
||||
acquisition_method="operator",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_caches() -> Iterator[None]:
|
||||
# Arrange: every test starts with empty FdrClient cache + empty
|
||||
# ImuPreintegrator cache so AC-623.2's "same instance across calls"
|
||||
# assertion is exercised against fresh state.
|
||||
fdr_client_module._reset_for_tests()
|
||||
clear_imu_preintegrator_cache()
|
||||
yield
|
||||
fdr_client_module._reset_for_tests()
|
||||
clear_imu_preintegrator_cache()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_az619_to_az622_builders(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange: stub the AZ-619 (clock seeded by build_pre_constructed
|
||||
# directly via WallClock — no builder), AZ-620 (Phase B) C6 builders,
|
||||
# AZ-621 (Phase C) C7 inference builder, and AZ-622 (Phase D) C3
|
||||
# builders so AZ-623 stays focused on the Phase E contract. Sentinels
|
||||
# are opaque on purpose — AZ-623 assertions never inspect them.
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c6_descriptor_index",
|
||||
lambda _config: MagicMock(name="DescriptorIndex"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c6_tile_store",
|
||||
lambda _config: MagicMock(name="TileStore"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c7_inference",
|
||||
lambda _config: MagicMock(name="InferenceRuntime"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c3_lightglue_runtime",
|
||||
lambda _config, *, inference_runtime: MagicMock(name="LightGlueRuntime"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_build_c3_feature_extractor",
|
||||
lambda _config: MagicMock(name="FeatureExtractor"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac_623_1_adds_c282_ransac_and_c5_helpers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange: a Config with a populated camera_calibration_path so
|
||||
# _build_c5_imu_preintegrator's empty-check passes. Stub
|
||||
# _load_camera_calibration to return a sentinel calibration so the
|
||||
# bootstrap does not touch the filesystem.
|
||||
config = _config_with_calibration_path("/tmp/az623-fixture-calib.json")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_load_camera_calibration",
|
||||
lambda _config: _make_calibration(),
|
||||
)
|
||||
|
||||
# Act
|
||||
pre_constructed = build_pre_constructed(config)
|
||||
|
||||
# Assert: each new key is present and typed per AC-623.1.
|
||||
assert "c282_ransac_filter" in pre_constructed
|
||||
assert isinstance(pre_constructed["c282_ransac_filter"], RansacFilter)
|
||||
|
||||
assert "c5_imu_preintegrator" in pre_constructed
|
||||
assert isinstance(pre_constructed["c5_imu_preintegrator"], ImuPreintegrator)
|
||||
|
||||
assert "c5_se3_utils" in pre_constructed
|
||||
se3_utils = pre_constructed["c5_se3_utils"]
|
||||
# The se3_utils handle is the helpers.se3_utils module — a module is
|
||||
# the simplest namespace object that exposes exp_map, log_map etc.
|
||||
# as attributes (matches the MagicMock fixture pattern existing C5
|
||||
# tests already use).
|
||||
assert se3_utils is se3_utils_module
|
||||
for func_name in ("exp_map", "log_map", "matrix_to_se3", "se3_to_matrix"):
|
||||
assert hasattr(se3_utils, func_name), (
|
||||
f"c5_se3_utils handle is missing {func_name!r}; "
|
||||
f"consumers (build_state_estimator, build_pose_estimator) "
|
||||
f"dispatch via attribute access."
|
||||
)
|
||||
|
||||
assert "c5_wgs_converter" in pre_constructed
|
||||
assert isinstance(pre_constructed["c5_wgs_converter"], WgsConverter)
|
||||
|
||||
|
||||
def test_ac_623_1_keeps_existing_keys_intact(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Arrange
|
||||
config = _config_with_calibration_path("/tmp/az623-fixture-calib.json")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_load_camera_calibration",
|
||||
lambda _config: _make_calibration(),
|
||||
)
|
||||
|
||||
# Act
|
||||
pre_constructed = build_pre_constructed(config)
|
||||
|
||||
# Assert: AZ-619..AZ-622 keys remain populated; AZ-623 is additive.
|
||||
assert {
|
||||
"c13_fdr",
|
||||
"clock",
|
||||
"c6_descriptor_index",
|
||||
"c6_tile_store",
|
||||
"c7_inference",
|
||||
"c3_lightglue_runtime",
|
||||
"c3_feature_extractor",
|
||||
}.issubset(pre_constructed.keys()), (
|
||||
f"AZ-623 must be additive on top of AZ-619..AZ-622; got "
|
||||
f"keys: {sorted(pre_constructed.keys())}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac_623_2_imu_preintegrator_cached_across_calls(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange: stub the calibration loader with a counter so we can
|
||||
# assert the cache short-circuit fires on the second call.
|
||||
config = _config_with_calibration_path("/tmp/az623-cache-fixture.json")
|
||||
load_count = {"n": 0}
|
||||
|
||||
def _counting_load(_config: Config) -> CameraCalibration:
|
||||
load_count["n"] += 1
|
||||
return _make_calibration()
|
||||
|
||||
monkeypatch.setattr(airborne_bootstrap, "_load_camera_calibration", _counting_load)
|
||||
|
||||
# Act
|
||||
first = build_pre_constructed(config)
|
||||
second = build_pre_constructed(config)
|
||||
|
||||
# Assert: cache holds the same ImuPreintegrator instance; the
|
||||
# calibration loader fires exactly once across the two calls.
|
||||
assert first["c5_imu_preintegrator"] is second["c5_imu_preintegrator"], (
|
||||
"AC-623.2 requires c5_imu_preintegrator to be the same instance "
|
||||
"across two build_pre_constructed calls (cache keyed on "
|
||||
"camera_calibration_path)"
|
||||
)
|
||||
assert load_count["n"] == 1, (
|
||||
f"calibration loader should fire once (cached on second call); "
|
||||
f"got {load_count['n']} invocations"
|
||||
)
|
||||
|
||||
|
||||
def test_ac_623_2_imu_preintegrator_per_path_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Arrange: two configs with DIFFERENT calibration paths. The cache
|
||||
# must produce DIFFERENT preintegrator instances (one per path).
|
||||
config_a = _config_with_calibration_path("/tmp/az623-path-a.json")
|
||||
config_b = _config_with_calibration_path("/tmp/az623-path-b.json")
|
||||
monkeypatch.setattr(
|
||||
airborne_bootstrap,
|
||||
"_load_camera_calibration",
|
||||
lambda _config: _make_calibration(),
|
||||
)
|
||||
|
||||
# Act
|
||||
a = build_pre_constructed(config_a)
|
||||
b = build_pre_constructed(config_b)
|
||||
|
||||
# Assert
|
||||
assert a["c5_imu_preintegrator"] is not b["c5_imu_preintegrator"], (
|
||||
"Two distinct calibration paths must produce two distinct "
|
||||
"ImuPreintegrator instances; the cache key is the path."
|
||||
)
|
||||
|
||||
|
||||
def test_ac_623_3_empty_calibration_path_raises_named_error() -> None:
|
||||
# Arrange: default Config() has camera_calibration_path="".
|
||||
config = Config()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as exc_info:
|
||||
build_pre_constructed(config)
|
||||
msg = str(exc_info.value)
|
||||
assert "c5_imu_preintegrator" in msg, msg
|
||||
assert "camera_calibration_path" in msg, msg
|
||||
assert "c5_state" in msg, msg
|
||||
|
||||
|
||||
def test_ac_623_3_unreadable_calibration_path_raises_named_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange: pass a path that does not exist on disk; the OSError
|
||||
# surfaces as AirborneBootstrapError with the operator-actionable
|
||||
# message.
|
||||
missing = tmp_path / "this-file-does-not-exist.json"
|
||||
config = _config_with_calibration_path(str(missing))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as exc_info:
|
||||
build_pre_constructed(config)
|
||||
msg = str(exc_info.value)
|
||||
assert "c5_imu_preintegrator" in msg
|
||||
assert "c5_state" in msg
|
||||
assert str(missing) in msg
|
||||
|
||||
|
||||
def test_ac_623_3_malformed_json_calibration_raises_named_error(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
# Arrange
|
||||
bad = tmp_path / "bad-calib.json"
|
||||
bad.write_text("{ this is not valid json", encoding="utf-8")
|
||||
config = _config_with_calibration_path(str(bad))
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(AirborneBootstrapError) as exc_info:
|
||||
build_pre_constructed(config)
|
||||
msg = str(exc_info.value)
|
||||
assert "c5_imu_preintegrator" in msg
|
||||
assert "c5_state" in msg
|
||||
assert "not valid JSON" in msg
|
||||
Reference in New Issue
Block a user