mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 23:51:12 +00:00
[AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers
AZ-270: composition root with strategy registry, tier-gated lookup, topo-order construction, all-or-nothing teardown, StrategyNotLinkedError payload. AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for unknown payload + top-level fields and canonical overrun-record shape. AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with WgsConversionError for shape/range/zoom guards. AZ-281: strict EngineFilenameSchema build/parse/matches_host with anchored regex + enum validation; round-trip identity by construction. AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with zero-norm safety and descriptor_metric() source-of-truth. pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and EngineCacheKey + HostCapabilities land in _types/ to back the helper contracts. 203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR deferrals + dep amendment + minor docstring polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""AZ-270 — Composition Root AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.runtime_root import (
|
||||
RuntimeRoot,
|
||||
StrategyNotLinkedError,
|
||||
clear_strategy_registry,
|
||||
compose_operator,
|
||||
compose_root,
|
||||
list_registered_strategies,
|
||||
register_strategy,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C1Block:
|
||||
strategy: str = "okvis2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C4Block:
|
||||
strategy: str = "opencv_gtsam"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C5Block:
|
||||
strategy: str = "gtsam_isam2"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C11Block:
|
||||
strategy: str = "ardupilot_tile_manager"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _OrderRecorder:
|
||||
constructed: list[str]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_registry() -> Iterator[None]:
|
||||
"""Reset the strategy registry around every test."""
|
||||
clear_strategy_registry()
|
||||
yield
|
||||
clear_strategy_registry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in (
|
||||
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
||||
("GPS_DENIED_TIER", "1"),
|
||||
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
||||
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
||||
("LOG_LEVEL", "INFO"),
|
||||
("LOG_SINK", "console"),
|
||||
("INFERENCE_BACKEND", "pytorch_fp16"),
|
||||
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
||||
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
||||
("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"),
|
||||
):
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _operator_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name, value in (
|
||||
("GPS_DENIED_FC_PROFILE", "ardupilot_plane"),
|
||||
("GPS_DENIED_TIER", "1"),
|
||||
("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"),
|
||||
("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"),
|
||||
("LOG_LEVEL", "INFO"),
|
||||
("LOG_SINK", "console"),
|
||||
("INFERENCE_BACKEND", "pytorch_fp16"),
|
||||
("FDR_PATH", "/var/lib/gps-denied/fdr"),
|
||||
("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"),
|
||||
("SATELLITE_PROVIDER_URL", "http://localhost:8080"),
|
||||
):
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
def _register_okvis2(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
recorder.constructed.append("c1_vio")
|
||||
return ("c1_vio", "okvis2")
|
||||
|
||||
register_strategy("c1_vio", "okvis2", factory, tier="airborne")
|
||||
|
||||
|
||||
def _register_c4(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
recorder.constructed.append("c4_pose")
|
||||
return ("c4_pose", "opencv_gtsam")
|
||||
|
||||
register_strategy("c4_pose", "opencv_gtsam", factory, tier="airborne")
|
||||
|
||||
|
||||
def _register_c5(recorder: _OrderRecorder) -> None:
|
||||
def factory(config: Config, components: dict[str, object]) -> object:
|
||||
assert "c1_vio" in components, "c5_state factory ran before c1_vio existed"
|
||||
assert "c4_pose" in components, "c5_state factory ran before c4_pose existed"
|
||||
recorder.constructed.append("c5_state")
|
||||
return ("c5_state", "gtsam_isam2")
|
||||
|
||||
register_strategy(
|
||||
"c5_state",
|
||||
"gtsam_isam2",
|
||||
factory,
|
||||
tier="airborne",
|
||||
depends_on=("c1_vio", "c4_pose"),
|
||||
)
|
||||
|
||||
|
||||
def test_ac1_default_deployment_composes(_airborne_env: None) -> None:
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
_register_c4(recorder)
|
||||
_register_c5(recorder)
|
||||
config = Config.with_blocks(
|
||||
c1_vio=_C1Block(),
|
||||
c4_pose=_C4Block(),
|
||||
c5_state=_C5Block(),
|
||||
)
|
||||
root = compose_root(config)
|
||||
assert isinstance(root, RuntimeRoot)
|
||||
assert root.binary == "airborne"
|
||||
assert set(root.components.keys()) == {"c1_vio", "c4_pose", "c5_state"}
|
||||
|
||||
|
||||
def test_ac2_strategy_not_linked_raises_with_payload(_airborne_env: None) -> None:
|
||||
# Only okvis2 is registered; config asks for vins_mono.
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(c1_vio=_C1Block(strategy="vins_mono"))
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_root(config)
|
||||
assert info.value.strategy_name == "vins_mono"
|
||||
assert info.value.component_slug == "c1_vio"
|
||||
assert info.value.available_strategies == ["okvis2"]
|
||||
|
||||
|
||||
def test_ac3_operator_excludes_airborne_only(_operator_env: None) -> None:
|
||||
# c1_vio is registered as airborne; an operator config that references it must fail.
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(c1_vio=_C1Block())
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_operator(config)
|
||||
assert info.value.component_slug == "c1_vio"
|
||||
assert "airborne" in info.value.reason or "tier" in info.value.reason
|
||||
|
||||
|
||||
def test_ac4_runtime_root_smoke_exit_zero(_airborne_env: None) -> None:
|
||||
# A Config with no component blocks must compose cleanly (every required
|
||||
# component is hard-wired by its bootstrap; no strategy to resolve).
|
||||
config = Config()
|
||||
root = compose_root(config)
|
||||
assert isinstance(root, RuntimeRoot)
|
||||
assert root.components == {}
|
||||
|
||||
|
||||
def test_ac5_construction_order_respects_dependencies(_airborne_env: None) -> None:
|
||||
recorder = _OrderRecorder(constructed=[])
|
||||
# Register in reverse order to make the topological pass non-trivial.
|
||||
_register_c5(recorder)
|
||||
_register_c4(recorder)
|
||||
_register_okvis2(recorder)
|
||||
config = Config.with_blocks(
|
||||
c1_vio=_C1Block(),
|
||||
c4_pose=_C4Block(),
|
||||
c5_state=_C5Block(),
|
||||
)
|
||||
root = compose_root(config)
|
||||
# Dependencies must construct strictly before dependents.
|
||||
assert recorder.constructed.index("c1_vio") < recorder.constructed.index("c5_state")
|
||||
assert recorder.constructed.index("c4_pose") < recorder.constructed.index("c5_state")
|
||||
assert root.construction_order[-1] == "c5_state"
|
||||
|
||||
|
||||
def test_ac6_only_compose_root_imports_concrete_strategies() -> None:
|
||||
"""Architecture lint: no module under ``components.*`` imports another component's concrete strategy.
|
||||
|
||||
We accept only:
|
||||
* the composition root (``runtime_root.py``);
|
||||
* per-component public re-exports inside the component's own subpackage
|
||||
(e.g. ``components.c5_state`` importing ``components.c5_state.interface``).
|
||||
Imports across components (e.g. ``components.c5_state`` importing
|
||||
``components.c1_vio.okvis2``) are violations.
|
||||
"""
|
||||
components_root = _REPO_ROOT / "src" / "gps_denied_onboard" / "components"
|
||||
violations: list[str] = []
|
||||
for module_path in components_root.rglob("*.py"):
|
||||
own_component = module_path.relative_to(components_root).parts[0]
|
||||
try:
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components."):
|
||||
referenced = node.module.split(".")[2]
|
||||
if referenced != own_component:
|
||||
violations.append(
|
||||
f"{module_path.relative_to(_REPO_ROOT)} imports {node.module}"
|
||||
)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components."):
|
||||
referenced = alias.name.split(".")[2]
|
||||
if referenced != own_component:
|
||||
violations.append(
|
||||
f"{module_path.relative_to(_REPO_ROOT)} imports {alias.name}"
|
||||
)
|
||||
assert not violations, (
|
||||
"components.* may not import other components — only the composition root may; "
|
||||
f"violations: {violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_nfr_reliability_partial_construction_closed_on_failure(_airborne_env: None) -> None:
|
||||
closed: list[str] = []
|
||||
|
||||
class _Closable:
|
||||
def __init__(self, slug: str) -> None:
|
||||
self.slug = slug
|
||||
|
||||
def close(self) -> None:
|
||||
closed.append(self.slug)
|
||||
|
||||
def good_factory(config: Config, components: dict[str, object]) -> _Closable:
|
||||
return _Closable("c1_vio")
|
||||
|
||||
def failing_factory(config: Config, components: dict[str, object]) -> object:
|
||||
raise RuntimeError("boom from c5_state factory")
|
||||
|
||||
register_strategy("c1_vio", "okvis2", good_factory, tier="airborne")
|
||||
register_strategy(
|
||||
"c5_state",
|
||||
"gtsam_isam2",
|
||||
failing_factory,
|
||||
tier="airborne",
|
||||
depends_on=("c1_vio",),
|
||||
)
|
||||
config = Config.with_blocks(c1_vio=_C1Block(), c5_state=_C5Block())
|
||||
with pytest.raises(RuntimeError, match=r"boom"):
|
||||
compose_root(config)
|
||||
assert closed == ["c1_vio"], "prior instances must be .close()d on mid-composition failure"
|
||||
|
||||
|
||||
def test_list_registered_strategies_returns_sorted_names() -> None:
|
||||
register_strategy("c1_vio", "okvis2", lambda c, m: None, tier="airborne")
|
||||
register_strategy("c1_vio", "vins_mono", lambda c, m: None, tier="airborne")
|
||||
register_strategy("c2_vpr", "netvlad", lambda c, m: None, tier="airborne")
|
||||
assert list_registered_strategies("c1_vio") == ["okvis2", "vins_mono"]
|
||||
assert list_registered_strategies("c2_vpr") == ["netvlad"]
|
||||
assert list_registered_strategies("c99_unknown") == []
|
||||
@@ -0,0 +1,256 @@
|
||||
"""AZ-272 — FdrRecord schema + versioned serialiser AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import orjson
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.fdr_client import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
KNOWN_KINDS,
|
||||
MAX_INLINE_BLOB_BYTES,
|
||||
OVERRUN_KIND,
|
||||
OVERRUN_PRODUCER_ID,
|
||||
FdrRecord,
|
||||
FdrSchemaError,
|
||||
parse,
|
||||
serialise,
|
||||
)
|
||||
|
||||
_TS = "2026-05-11T00:00:00.000000Z"
|
||||
|
||||
|
||||
def _kind_payload(kind: str) -> dict[str, object]:
|
||||
"""Return a minimal valid payload for each v1.0.0 kind."""
|
||||
if kind == "log":
|
||||
return {
|
||||
"level": "INFO",
|
||||
"component": "c2_vpr",
|
||||
"frame_id": 42,
|
||||
"kind": "vpr.warmup",
|
||||
"msg": "loaded",
|
||||
"kv": {"model": "salad"},
|
||||
}
|
||||
if kind == "vio.tick":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"R": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
||||
"t": [0.0, 0.0, 0.0],
|
||||
"P": [[1.0]],
|
||||
"last_anchor_age_ms": 100,
|
||||
}
|
||||
if kind == "state.tick":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"fused_pose": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
"covariance_2x2": [[1.0, 0.0], [0.0, 1.0]],
|
||||
"estimator_label": "primary",
|
||||
}
|
||||
if kind == "tile_match":
|
||||
return {
|
||||
"frame_id": 1,
|
||||
"tile_id": "tile-001",
|
||||
"score": 0.85,
|
||||
"match_count": 124,
|
||||
"ransac_inliers": 96,
|
||||
}
|
||||
if kind == "overrun":
|
||||
return {"producer_id": "c1_vio", "dropped_count": 42}
|
||||
if kind == "segment_rollover":
|
||||
return {
|
||||
"old_segment": "seg-0001",
|
||||
"new_segment": "seg-0002",
|
||||
"total_bytes_after": 1024 * 1024,
|
||||
}
|
||||
if kind == "failed_tile_thumbnail":
|
||||
return {"frame_id": 1, "tile_id": "tile-002", "jpeg_bytes_b64": "AAAAAA=="}
|
||||
if kind == "mid_flight_tile_snapshot":
|
||||
return {"snapshot_path": "/var/lib/gps-denied/snap.dat", "captured_at": _TS}
|
||||
if kind == "flight_header":
|
||||
return {
|
||||
"flight_id": "f-0001",
|
||||
"started_at": _TS,
|
||||
"schema_version": CURRENT_SCHEMA_VERSION,
|
||||
"build_info": {"commit": "abc123"},
|
||||
}
|
||||
if kind == "flight_footer":
|
||||
return {
|
||||
"flight_id": "f-0001",
|
||||
"ended_at": _TS,
|
||||
"records_written": 12345,
|
||||
"records_dropped": 0,
|
||||
}
|
||||
raise AssertionError(f"unhandled kind in fixture: {kind!r}")
|
||||
|
||||
|
||||
def _make_record(kind: str, **overrides: object) -> FdrRecord:
|
||||
"""Build a minimal valid record for ``kind`` so each AC test can mutate the field it cares about."""
|
||||
if kind == OVERRUN_KIND:
|
||||
producer_id = overrides.pop("producer_id", OVERRUN_PRODUCER_ID)
|
||||
else:
|
||||
producer_id = overrides.pop("producer_id", "c2_vpr")
|
||||
return FdrRecord(
|
||||
schema_version=int(overrides.pop("schema_version", CURRENT_SCHEMA_VERSION)),
|
||||
ts=str(overrides.pop("ts", _TS)),
|
||||
producer_id=str(producer_id),
|
||||
kind=str(overrides.pop("kind", kind)),
|
||||
payload=dict(overrides.pop("payload", _kind_payload(kind))), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", sorted(KNOWN_KINDS))
|
||||
def test_ac1_roundtrip_every_known_kind(kind: str) -> None:
|
||||
# Arrange
|
||||
record = _make_record(kind)
|
||||
# Act
|
||||
decoded = parse(serialise(record))
|
||||
# Assert
|
||||
assert decoded == record
|
||||
|
||||
|
||||
def test_ac2_forward_compatible_unknown_payload_field_preserved() -> None:
|
||||
# Arrange — synthesise a future v1.1 record adding an unknown payload field.
|
||||
payload = _kind_payload("log") | {"new_field": "x"}
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
# Act
|
||||
record = parse(wire)
|
||||
# Assert
|
||||
assert record.payload.get("extra", {}).get("new_field") == "x"
|
||||
assert "new_field" not in record.payload
|
||||
# Known fields still parse out cleanly.
|
||||
assert record.payload["msg"] == "loaded"
|
||||
|
||||
|
||||
def test_ac2b_forward_compatible_unknown_top_level_field_preserved() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
"trailing": "future-field",
|
||||
}
|
||||
)
|
||||
record = parse(wire)
|
||||
assert record.extra == {"trailing": "future-field"}
|
||||
|
||||
|
||||
def test_ac3_unknown_future_kind_returned_opaquely() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": "future.producer",
|
||||
"kind": "future.kind",
|
||||
"payload": {"foo": 1},
|
||||
}
|
||||
)
|
||||
record = parse(wire)
|
||||
assert record.kind == "future.kind"
|
||||
assert record.payload == {"foo": 1}
|
||||
assert record.extra == {}
|
||||
|
||||
|
||||
def test_ac4_missing_schema_version_raises() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"schema_version"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac4_non_integer_schema_version_raises() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"ts": _TS,
|
||||
"producer_id": "c2_vpr",
|
||||
"kind": "log",
|
||||
"payload": _kind_payload("log"),
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"schema_version"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac5_overrun_missing_dropped_count_rejected_on_parse() -> None:
|
||||
wire = orjson.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"ts": _TS,
|
||||
"producer_id": OVERRUN_PRODUCER_ID,
|
||||
"kind": OVERRUN_KIND,
|
||||
"payload": {"producer_id": "c1_vio"},
|
||||
}
|
||||
)
|
||||
with pytest.raises(FdrSchemaError, match=r"dropped_count"):
|
||||
parse(wire)
|
||||
|
||||
|
||||
def test_ac5_overrun_zero_dropped_count_rejected_on_serialise() -> None:
|
||||
record = _make_record(OVERRUN_KIND, payload={"producer_id": "c1_vio", "dropped_count": 0})
|
||||
with pytest.raises(FdrSchemaError, match=r"dropped_count"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_ac6_empty_producer_id_rejected_on_serialise() -> None:
|
||||
record = _make_record("log", producer_id="")
|
||||
with pytest.raises(FdrSchemaError, match=r"producer_id"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_nfr_oversized_inline_blob_rejected() -> None:
|
||||
blob = b"\x00" * (MAX_INLINE_BLOB_BYTES + 1)
|
||||
payload = _kind_payload("failed_tile_thumbnail") | {"jpeg_bytes": blob}
|
||||
record = _make_record("failed_tile_thumbnail", payload=payload)
|
||||
with pytest.raises(FdrSchemaError, match=r"sidecar path"):
|
||||
serialise(record)
|
||||
|
||||
|
||||
def test_nfr_serialise_is_pure_byte_identical() -> None:
|
||||
record = _make_record("log")
|
||||
first = serialise(record)
|
||||
second = serialise(record)
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_no_upward_imports_to_components() -> None:
|
||||
"""Layer-0/cross-cutting: ``fdr_client.records`` must not import any component."""
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "fdr_client"
|
||||
/ "records.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"fdr_client.records must not import components.*; found: {bad}"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""AZ-279 — WgsConverter helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/wgs_converter.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.helpers import (
|
||||
MAX_ZOOM,
|
||||
WEB_MERCATOR_MAX_LAT_DEG,
|
||||
WgsConversionError,
|
||||
WgsConverter,
|
||||
)
|
||||
|
||||
|
||||
def test_ac1_ecef_roundtrip() -> None:
|
||||
# Arrange
|
||||
samples = [
|
||||
LatLonAlt(50.0, 30.0, 100.0),
|
||||
LatLonAlt(-33.9, 151.2, 25.0),
|
||||
LatLonAlt(0.0, 0.0, 0.0),
|
||||
LatLonAlt(80.0, -120.0, 1500.0),
|
||||
LatLonAlt(-60.0, 179.999, -50.0),
|
||||
]
|
||||
# Act / Assert
|
||||
for p in samples:
|
||||
ecef = WgsConverter.latlonalt_to_ecef(p)
|
||||
back = WgsConverter.ecef_to_latlonalt(ecef)
|
||||
assert math.isclose(back.lat_deg, p.lat_deg, abs_tol=1e-9), (back, p)
|
||||
assert math.isclose(back.lon_deg, p.lon_deg, abs_tol=1e-9), (back, p)
|
||||
assert math.isclose(back.alt_m, p.alt_m, abs_tol=1e-6), (back, p)
|
||||
|
||||
|
||||
def test_ac2_enu_roundtrip_within_10_km() -> None:
|
||||
origin = LatLonAlt(50.0, 30.0, 100.0)
|
||||
# ~10 km away in NE direction
|
||||
p = LatLonAlt(50.07, 30.10, 250.0)
|
||||
enu = WgsConverter.latlonalt_to_local_enu(origin, p)
|
||||
back = WgsConverter.local_enu_to_latlonalt(origin, enu)
|
||||
horizontal_m = math.hypot(
|
||||
(back.lat_deg - p.lat_deg) * 111_320.0,
|
||||
(back.lon_deg - p.lon_deg) * 111_320.0 * math.cos(math.radians(p.lat_deg)),
|
||||
)
|
||||
vertical_m = abs(back.alt_m - p.alt_m)
|
||||
assert horizontal_m < 1.0, f"horizontal residual {horizontal_m} m > 1 m"
|
||||
assert vertical_m < 0.01, f"vertical residual {vertical_m} m > 1 cm"
|
||||
|
||||
|
||||
def test_ac3_slippy_map_tile_roundtrip_z18_contains_input() -> None:
|
||||
zoom, lat, lon = 18, 50.45, 30.52
|
||||
x, y = WgsConverter.latlon_to_tile_xy(zoom, lat, lon)
|
||||
bounds = WgsConverter.tile_xy_to_latlon_bounds(zoom, x, y)
|
||||
assert isinstance(bounds, BoundingBox)
|
||||
assert bounds.contains(lat, lon)
|
||||
# OSM-pinned reference for (lat=50.45, lon=30.52, z=18); precomputed via
|
||||
# the slippy-map formula and matching satellite-provider's on-disk layout.
|
||||
assert (x, y) == (153295, 88392)
|
||||
|
||||
|
||||
def test_ac4_web_mercator_latitude_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"Web-Mercator"):
|
||||
WgsConverter.latlon_to_tile_xy(18, 95.0, 0.0)
|
||||
|
||||
|
||||
def test_ac5_zoom_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"zoom"):
|
||||
WgsConverter.latlon_to_tile_xy(MAX_ZOOM + 3, 50.0, 30.0)
|
||||
with pytest.raises(WgsConversionError, match=r"zoom"):
|
||||
WgsConverter.tile_xy_to_latlon_bounds(MAX_ZOOM + 3, 0, 0)
|
||||
|
||||
|
||||
def test_ac6_tile_xy_range_guard() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"tile"):
|
||||
WgsConverter.tile_xy_to_latlon_bounds(18, 1 << 18, 0)
|
||||
|
||||
|
||||
def test_ac7_ecef_shape_contract() -> None:
|
||||
with pytest.raises(WgsConversionError, match=r"shape"):
|
||||
WgsConverter.ecef_to_latlonalt(np.array([1.0, 2.0], dtype=np.float64))
|
||||
|
||||
|
||||
def test_ac8_determinism_byte_equal_outputs() -> None:
|
||||
p = LatLonAlt(50.0, 30.0, 100.0)
|
||||
first = WgsConverter.latlonalt_to_ecef(p)
|
||||
second = WgsConverter.latlonalt_to_ecef(p)
|
||||
assert first.tobytes() == second.tobytes()
|
||||
|
||||
|
||||
def test_ac9_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "wgs_converter.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"wgs_converter must not import components.*; found: {bad}"
|
||||
|
||||
|
||||
def test_invariant_web_mercator_max_lat_close_to_documented_value() -> None:
|
||||
# Sanity bound: documented constant matches the Mercator-projection-valid
|
||||
# latitude (arctan(sinh(pi))) within rounding.
|
||||
expected = math.degrees(math.atan(math.sinh(math.pi)))
|
||||
assert math.isclose(WEB_MERCATOR_MAX_LAT_DEG, expected, abs_tol=1e-9)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""AZ-281 — EngineFilenameSchema helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/engine_filename_schema.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard._types.manifests import EngineCacheKey, HostCapabilities
|
||||
from gps_denied_onboard.helpers import EngineFilenameSchema, EngineFilenameSchemaError
|
||||
|
||||
|
||||
def test_ac1_reference_example_builds_exact_string() -> None:
|
||||
assert (
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
== "ultravpr__sm87_jp6.2_trt10.3_fp16.engine"
|
||||
)
|
||||
|
||||
|
||||
def test_ac2_roundtrip_identity_over_10_random_tuples() -> None:
|
||||
rng = random.Random(2026)
|
||||
for _ in range(10):
|
||||
model = rng.choice(["ultravpr", "netvlad", "megaloc", "selavpr", "salad", "mix_vpr"])
|
||||
sm = rng.choice([72, 86, 87])
|
||||
jp = rng.choice(["5.1", "6.0", "6.2", "6.3"])
|
||||
trt = rng.choice(["8.6", "10.0", "10.3", "10.4"])
|
||||
prec = rng.choice(["fp16", "int8", "mixed"])
|
||||
filename = EngineFilenameSchema.build(model, sm, jp, trt, prec)
|
||||
parsed = EngineFilenameSchema.parse(filename)
|
||||
assert parsed == EngineCacheKey(model, sm, jp, trt, prec)
|
||||
|
||||
|
||||
def test_ac3_matches_host_exact_match() -> None:
|
||||
filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
host = HostCapabilities(sm=87, jetpack="6.2", trt="10.3")
|
||||
assert EngineFilenameSchema.matches_host(filename, host) is True
|
||||
|
||||
|
||||
def test_ac4_matches_host_tuple_mismatch_returns_false() -> None:
|
||||
filename = EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "fp16")
|
||||
host_mismatch = HostCapabilities(sm=72, jetpack="6.2", trt="10.3")
|
||||
assert EngineFilenameSchema.matches_host(filename, host_mismatch) is False
|
||||
host_mismatch_trt = HostCapabilities(sm=87, jetpack="6.2", trt="10.4")
|
||||
assert EngineFilenameSchema.matches_host(filename, host_mismatch_trt) is False
|
||||
|
||||
|
||||
def test_ac5_precision_enum_strictness() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"precision"):
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2", "10.3", "bf16")
|
||||
|
||||
|
||||
def test_ac6_model_name_character_set_rejection() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"a-z0-9_"):
|
||||
EngineFilenameSchema.build("UltraVPR", 87, "6.2", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac7_reserved_separator_collision_rejected() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"__"):
|
||||
EngineFilenameSchema.build("ultra__vpr", 87, "6.2", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac8_three_segment_version_rejected() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"major.*minor"):
|
||||
EngineFilenameSchema.build("ultravpr", 87, "6.2.1", "10.3", "fp16")
|
||||
|
||||
|
||||
def test_ac9_parse_rejects_malformed_filename() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError):
|
||||
EngineFilenameSchema.parse("not_an_engine_file.engine")
|
||||
|
||||
|
||||
def test_ac10_parse_requires_engine_suffix() -> None:
|
||||
with pytest.raises(EngineFilenameSchemaError, match=r"\.engine"):
|
||||
EngineFilenameSchema.parse("ultravpr__sm87_jp6.2_trt10.3_fp16")
|
||||
|
||||
|
||||
def test_ac11_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "engine_filename_schema.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"engine_filename_schema must not import components.*; found: {bad}"
|
||||
@@ -0,0 +1,122 @@
|
||||
"""AZ-283 — DescriptorNormaliser helper AC tests.
|
||||
|
||||
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.helpers import DescriptorNormaliser, DescriptorNormaliserError
|
||||
|
||||
|
||||
def test_ac1_unit_vector_example() -> None:
|
||||
out = DescriptorNormaliser.l2_normalise(np.array([3.0, 4.0], dtype=np.float32))
|
||||
np.testing.assert_allclose(out, np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
|
||||
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac2_batch_normalisation() -> None:
|
||||
batch = np.array([[3.0, 4.0], [1.0, 0.0]], dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise_batch(batch)
|
||||
np.testing.assert_allclose(out[0], np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
|
||||
np.testing.assert_allclose(out[1], np.array([1.0, 0.0], dtype=np.float32), atol=1e-6)
|
||||
for row in out:
|
||||
assert float(np.linalg.norm(row)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac3_fp16_dtype_preservation() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(512).astype(np.float16)
|
||||
out = DescriptorNormaliser.l2_normalise(x)
|
||||
assert out.dtype == np.float16
|
||||
assert float(np.linalg.norm(out.astype(np.float32))) == pytest.approx(1.0, abs=1e-3)
|
||||
|
||||
|
||||
def test_ac4_fp32_dtype_preservation() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(512).astype(np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise(x)
|
||||
assert out.dtype == np.float32
|
||||
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_ac5_zero_vector_handling() -> None:
|
||||
zeros = np.zeros(128, dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise(zeros)
|
||||
np.testing.assert_array_equal(out, zeros)
|
||||
assert not np.any(np.isnan(out))
|
||||
|
||||
|
||||
def test_ac5b_zero_row_in_batch_remains_zero() -> None:
|
||||
batch = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32)
|
||||
out = DescriptorNormaliser.l2_normalise_batch(batch)
|
||||
np.testing.assert_array_equal(out[0], np.zeros(3, dtype=np.float32))
|
||||
np.testing.assert_allclose(out[1], np.array([1.0, 0.0, 0.0], dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac6_idempotence_fp32() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(64).astype(np.float32)
|
||||
once = DescriptorNormaliser.l2_normalise(x)
|
||||
twice = DescriptorNormaliser.l2_normalise(once)
|
||||
assert once.tobytes() == twice.tobytes()
|
||||
|
||||
|
||||
def test_ac7_idempotence_fp16_within_half_precision_tol() -> None:
|
||||
rng = np.random.default_rng(2026)
|
||||
x = rng.standard_normal(64).astype(np.float16)
|
||||
once = DescriptorNormaliser.l2_normalise(x)
|
||||
twice = DescriptorNormaliser.l2_normalise(once)
|
||||
np.testing.assert_allclose(twice.astype(np.float32), once.astype(np.float32), atol=1e-3)
|
||||
|
||||
|
||||
def test_ac8_no_in_place_mutation() -> None:
|
||||
x = np.array([3.0, 4.0, 0.0], dtype=np.float32)
|
||||
snapshot = x.copy()
|
||||
_ = DescriptorNormaliser.l2_normalise(x)
|
||||
np.testing.assert_array_equal(x, snapshot)
|
||||
|
||||
|
||||
def test_ac9_metric_is_inner_product_exact_string() -> None:
|
||||
assert DescriptorNormaliser.descriptor_metric() == "inner_product"
|
||||
|
||||
|
||||
def test_ac10_float64_dtype_rejected() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"float16.*float32|float32.*float16"):
|
||||
DescriptorNormaliser.l2_normalise(np.array([1.0, 2.0], dtype=np.float64))
|
||||
|
||||
|
||||
def test_ac11_shape_contract_single_rejects_2d() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"1-D"):
|
||||
DescriptorNormaliser.l2_normalise(np.zeros((2, 3), dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac11_shape_contract_batch_rejects_1d() -> None:
|
||||
with pytest.raises(DescriptorNormaliserError, match=r"2-D"):
|
||||
DescriptorNormaliser.l2_normalise_batch(np.zeros(128, dtype=np.float32))
|
||||
|
||||
|
||||
def test_ac12_no_upward_imports_to_components() -> None:
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "descriptor_normaliser.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text(encoding="utf-8"))
|
||||
bad: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
if node.module.startswith("gps_denied_onboard.components"):
|
||||
bad.append(node.module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad.append(alias.name)
|
||||
assert not bad, f"descriptor_normaliser must not import components.*; found: {bad}"
|
||||
@@ -1,7 +1,13 @@
|
||||
"""Runtime-root env-var fail-fast — AZ-263 AC-8."""
|
||||
"""Runtime-root env-var fail-fast — AZ-263 AC-8 (updated by AZ-270 to pass a Config).
|
||||
|
||||
AZ-270 swapped ``compose_root()`` to ``compose_root(config: Config)``; the
|
||||
env-var fail-fast still happens inside ``compose_root`` before any factory
|
||||
construction, so this AC-8 contract is intact.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.runtime_root import ConfigurationError, compose_root
|
||||
|
||||
|
||||
@@ -23,7 +29,7 @@ def test_compose_root_fails_fast_on_missing_required(monkeypatch: pytest.MonkeyP
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
compose_root(Config())
|
||||
assert "Missing required environment variable" in str(excinfo.value)
|
||||
|
||||
|
||||
@@ -45,7 +51,7 @@ def test_compose_root_names_the_first_missing_var(monkeypatch: pytest.MonkeyPatc
|
||||
|
||||
# Act
|
||||
with pytest.raises(ConfigurationError) as excinfo:
|
||||
compose_root()
|
||||
compose_root(Config())
|
||||
|
||||
# Assert
|
||||
msg = str(excinfo.value)
|
||||
|
||||
Reference in New Issue
Block a user