mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:21:16 +00:00
[AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers
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>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user