mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 14:01:13 +00:00
8e71f6c002
AZ-266: schema-compliant JSON logging entrypoint, level normalisation, handler-topology guard, format-error fallback (log_record_schema v1.0.0). AZ-269: env > YAML > defaults config loader, frozen Config dataclass, missing-var fail-fast with pointer to .env.example, component-block registry. AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict orthogonality, dtype, and bottom-row contract enforcement. AZ-280: atomicwrites-backed write_atomic + independent verify + order-deterministic aggregate_hash; sidecar format strictness. pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0 (named-backend deps per the AZ-277 / AZ-280 contracts). 139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR + journald deferrals, no blocking issues. Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
5.7 KiB
Python
204 lines
5.7 KiB
Python
"""AC tests for AZ-277: SE3Utils helper module.
|
|
|
|
Verifies the `se3_utils` contract v1.0.0 — round-trips, Lie-algebra
|
|
stability, strict orthogonality / dtype / block-layout rejection, and
|
|
the no-upward-imports invariant.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import gtsam
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard.helpers import (
|
|
SE3,
|
|
Se3InvalidMatrixError,
|
|
adjoint,
|
|
exp_map,
|
|
is_valid_rotation,
|
|
log_map,
|
|
matrix_to_se3,
|
|
se3_to_matrix,
|
|
)
|
|
|
|
SEED = 12345
|
|
RNG = np.random.default_rng(SEED)
|
|
|
|
|
|
def _random_valid_T(rng: np.random.Generator) -> np.ndarray:
|
|
"""Produce a valid SE(3) 4x4 matrix from a random twist."""
|
|
xi = rng.standard_normal(6) * 0.5
|
|
pose = gtsam.Pose3.Expmap(xi.astype(np.float64))
|
|
return np.ascontiguousarray(pose.matrix(), dtype=np.float64)
|
|
|
|
|
|
def test_ac1_matrix_se3_roundtrip_within_tolerance() -> None:
|
|
# Arrange + Act + Assert
|
|
for _ in range(20):
|
|
T = _random_valid_T(RNG)
|
|
pose = matrix_to_se3(T)
|
|
recovered = se3_to_matrix(pose)
|
|
assert np.allclose(recovered, T, atol=1e-9), (
|
|
f"matrix_to_se3 -> se3_to_matrix round-trip diverged for T:\n{T}"
|
|
)
|
|
|
|
|
|
def test_ac2_lie_algebra_roundtrip() -> None:
|
|
# Arrange + Act + Assert
|
|
for _ in range(20):
|
|
xi = RNG.standard_normal(6).astype(np.float64)
|
|
norm = float(np.linalg.norm(xi))
|
|
if norm == 0.0:
|
|
continue
|
|
xi *= 1.0 / norm # norm ~= 1.0
|
|
recovered = log_map(exp_map(xi))
|
|
assert np.allclose(recovered, xi, atol=1e-9), (
|
|
f"log_map(exp_map(xi)) round-trip diverged for xi={xi}"
|
|
)
|
|
|
|
|
|
def test_ac3_near_identity_exp_map_is_stable() -> None:
|
|
# Arrange
|
|
xi = np.full(6, 1e-12, dtype=np.float64)
|
|
|
|
# Act
|
|
pose = exp_map(xi)
|
|
matrix = se3_to_matrix(pose)
|
|
|
|
# Assert
|
|
identity = np.eye(4, dtype=np.float64)
|
|
assert np.allclose(matrix, identity, atol=1e-9), (
|
|
f"near-identity exp_map must return identity within atol=1e-9; got\n{matrix}"
|
|
)
|
|
assert not np.any(np.isnan(matrix)), "near-identity exp_map must not produce NaNs"
|
|
|
|
|
|
def test_ac4_strict_orthogonality_rejection() -> None:
|
|
# Arrange — drift R^T R away from I by 1e-3 in Frobenius norm
|
|
T = _random_valid_T(RNG)
|
|
T[:3, :3] += np.eye(3, dtype=np.float64) * 1e-3
|
|
|
|
# Act + Assert
|
|
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
|
matrix_to_se3(T)
|
|
msg = str(excinfo.value)
|
|
assert "orthogonal" in msg.lower() or "R^T R" in msg, (
|
|
"rejection message must explain the orthogonality drift"
|
|
)
|
|
|
|
|
|
def test_ac5_mirror_rotation_rejected() -> None:
|
|
# Arrange — flip the sign of one column to produce det(R) = -1
|
|
T = _random_valid_T(RNG)
|
|
T[:3, 0] = -T[:3, 0]
|
|
|
|
# Act + Assert
|
|
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
|
matrix_to_se3(T)
|
|
msg = str(excinfo.value)
|
|
assert "determinant" in msg.lower() or "negative" in msg.lower()
|
|
|
|
|
|
def test_ac6_bottom_row_guard() -> None:
|
|
# Arrange
|
|
T = _random_valid_T(RNG)
|
|
T[3] = np.array([0.0, 0.0, 0.0, 2.0], dtype=np.float64)
|
|
|
|
# Act + Assert
|
|
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
|
matrix_to_se3(T)
|
|
assert "bottom row" in str(excinfo.value).lower()
|
|
|
|
|
|
def test_ac7_float32_dtype_rejected() -> None:
|
|
# Arrange
|
|
T = _random_valid_T(RNG).astype(np.float32)
|
|
|
|
# Act + Assert
|
|
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
|
matrix_to_se3(T)
|
|
assert "dtype" in str(excinfo.value).lower() or "float" in str(excinfo.value).lower()
|
|
|
|
|
|
def test_ac8_determinism_byte_equal_outputs() -> None:
|
|
# Arrange
|
|
T = _random_valid_T(RNG)
|
|
|
|
# Act
|
|
pose_a = matrix_to_se3(T)
|
|
pose_b = matrix_to_se3(T)
|
|
matrix_a = se3_to_matrix(pose_a)
|
|
matrix_b = se3_to_matrix(pose_b)
|
|
|
|
# Assert
|
|
assert np.array_equal(matrix_a, matrix_b), (
|
|
"matrix_to_se3 -> se3_to_matrix must be byte-equal across calls"
|
|
)
|
|
|
|
|
|
def test_ac9_no_upward_imports_to_components() -> None:
|
|
"""The helper module MUST NOT import from gps_denied_onboard.components.*."""
|
|
# Arrange
|
|
module_path = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "src"
|
|
/ "gps_denied_onboard"
|
|
/ "helpers"
|
|
/ "se3_utils.py"
|
|
)
|
|
tree = ast.parse(module_path.read_text())
|
|
bad_imports: list[str] = []
|
|
|
|
# Act
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
module = node.module or ""
|
|
if module.startswith("gps_denied_onboard.components"):
|
|
bad_imports.append(module)
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.startswith("gps_denied_onboard.components"):
|
|
bad_imports.append(alias.name)
|
|
|
|
# Assert
|
|
assert not bad_imports, (
|
|
f"helpers.se3_utils must not import from components.*; found {bad_imports}"
|
|
)
|
|
|
|
|
|
def test_is_valid_rotation_predicate() -> None:
|
|
# Arrange
|
|
R_good = np.eye(3, dtype=np.float64)
|
|
R_drift = np.eye(3, dtype=np.float64) + 1e-2 # off-orthogonal
|
|
R_mirror = np.diag([-1.0, 1.0, 1.0]).astype(np.float64)
|
|
R_f32 = np.eye(3, dtype=np.float32)
|
|
|
|
# Assert
|
|
assert is_valid_rotation(R_good) is True
|
|
assert is_valid_rotation(R_drift) is False
|
|
assert is_valid_rotation(R_mirror) is False
|
|
assert is_valid_rotation(R_f32) is False
|
|
assert is_valid_rotation("not an array") is False # type: ignore[arg-type]
|
|
|
|
|
|
def test_adjoint_shape() -> None:
|
|
# Arrange
|
|
T = _random_valid_T(RNG)
|
|
pose = matrix_to_se3(T)
|
|
|
|
# Act
|
|
ad = adjoint(pose)
|
|
|
|
# Assert
|
|
assert ad.shape == (6, 6)
|
|
assert ad.dtype == np.float64
|
|
|
|
|
|
def test_se3_is_gtsam_pose3_alias() -> None:
|
|
# Assert
|
|
assert SE3 is gtsam.Pose3
|