195 lines
5.7 KiB
Python
195 lines
5.7 KiB
Python
"""Tests for cached, session-balanced Phase-A comparison."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from imu_lidar.contracts import ImuSeries, MotionPair
|
|
from imu_lidar.geometry import so3_exp, so3_log
|
|
from imu_lidar.imu_preintegration import preintegrate_gyro
|
|
from imu_lidar.phase_a import (
|
|
rehydrate_phase_a_pairs,
|
|
select_decorrelated_phase_a_pairs,
|
|
solve_phase_a_comparison,
|
|
)
|
|
|
|
|
|
def _phase_a_pair(
|
|
session_id: str,
|
|
index: int,
|
|
r_true: np.ndarray,
|
|
vector_deg: tuple[float, float, float],
|
|
bias0: np.ndarray,
|
|
) -> MotionPair:
|
|
r_b = so3_exp(np.deg2rad(np.asarray(vector_deg, dtype=float)))
|
|
return MotionPair(
|
|
session_id=session_id,
|
|
i=index,
|
|
j=index + 1,
|
|
t_i_s=float(index),
|
|
t_j_s=float(index + 1),
|
|
R_A=r_true @ r_b @ r_true.T,
|
|
R_B=r_b,
|
|
t_A_m=np.zeros(3),
|
|
t_B_m=np.zeros(3),
|
|
metadata={
|
|
"J_bg": (-np.eye(3)).tolist(),
|
|
"cov": (np.eye(3) * 1e-5).tolist(),
|
|
"gyro_bias0_rad_s": bias0.tolist(),
|
|
},
|
|
)
|
|
|
|
|
|
def test_phase_a_reports_three_variants_and_leave_one_session_out() -> None:
|
|
r_true = so3_exp(np.deg2rad(np.array([3.0, -2.0, 25.0])))
|
|
prior = so3_exp(np.deg2rad(np.array([0.0, 0.0, 0.2]))) @ r_true
|
|
vectors = (
|
|
(12.0, 0.0, 0.0),
|
|
(0.0, 15.0, 0.0),
|
|
(0.0, 0.0, 18.0),
|
|
(10.0, 8.0, 0.0),
|
|
(0.0, 11.0, 9.0),
|
|
(7.0, 0.0, 13.0),
|
|
(9.0, -5.0, 6.0),
|
|
(-6.0, 8.0, 11.0),
|
|
(5.0, 7.0, -9.0),
|
|
)
|
|
biases = {
|
|
"s0": np.array([0.001, -0.0005, 0.0002]),
|
|
"s1": np.array([-0.0004, 0.0008, -0.0001]),
|
|
"s2": np.array([0.0002, 0.0001, -0.0006]),
|
|
}
|
|
pairs: list[MotionPair] = []
|
|
index = 0
|
|
for sid, count in (("s0", 18), ("s1", 9), ("s2", 6)):
|
|
for local_index in range(count):
|
|
pairs.append(
|
|
_phase_a_pair(
|
|
sid,
|
|
index,
|
|
r_true,
|
|
vectors[local_index % len(vectors)],
|
|
biases[sid],
|
|
)
|
|
)
|
|
index += 1
|
|
|
|
result = solve_phase_a_comparison(
|
|
pairs,
|
|
gyro_bias_rad_s_by_session=biases,
|
|
rotation_prior=prior,
|
|
rotation_prior_sigma_deg=15.0,
|
|
yaw_std_max_deg=1.0,
|
|
leave_one_out_yaw_range_max_deg=1.0,
|
|
data_prior_difference_max_deg=1.0,
|
|
decorrelation_block_s=0.0,
|
|
max_nfev=80,
|
|
)
|
|
|
|
assert result.accepted
|
|
assert result.strong_pair_counts_per_session == {
|
|
"s0": 18,
|
|
"s1": 9,
|
|
"s2": 6,
|
|
}
|
|
assert len(result.leave_one_out) == 3
|
|
assert result.marginal_observability.rank == 3
|
|
assert result.leave_one_out_yaw_range_deg < 0.1
|
|
for variant in (
|
|
result.fixed_bg_data_only,
|
|
result.session_bg_data_only,
|
|
result.session_bg_with_rotation_prior,
|
|
):
|
|
error_deg = np.degrees(
|
|
np.linalg.norm(
|
|
so3_log(r_true.T @ variant.R_IMU_lidar)
|
|
)
|
|
)
|
|
assert error_deg < 0.1
|
|
|
|
|
|
def test_rehydrate_phase_a_pairs_recovers_jacobian_without_lidar() -> None:
|
|
t_s = np.linspace(0.0, 1.0, 201)
|
|
gyro = np.tile(np.array([0.12, -0.04, 0.2]), (t_s.size, 1))
|
|
bias0 = np.array([0.01, -0.005, 0.002])
|
|
imu = ImuSeries(
|
|
t_s=t_s,
|
|
gyro_rad_s=gyro,
|
|
acc_m_s2=np.zeros((t_s.size, 3)),
|
|
)
|
|
preint = preintegrate_gyro(t_s, gyro, 0.1, 0.8, bias0)
|
|
pair = MotionPair(
|
|
session_id="s0",
|
|
i=0,
|
|
j=1,
|
|
t_i_s=0.1,
|
|
t_j_s=0.8,
|
|
R_A=preint.delta_R,
|
|
R_B=preint.delta_R,
|
|
metadata={
|
|
"t_i_imu_s": 0.1,
|
|
"t_j_imu_s": 0.8,
|
|
"gyro_bias0_rad_s": bias0.tolist(),
|
|
"preint_sigma_rad": preint.sigma_rad,
|
|
},
|
|
)
|
|
|
|
enriched, report = rehydrate_phase_a_pairs(
|
|
[pair],
|
|
imu_by_session={"s0": imu},
|
|
bias0_by_session={"s0": bias0},
|
|
)
|
|
|
|
assert "J_bg" in enriched[0].metadata
|
|
assert "cov" in enriched[0].metadata
|
|
assert report["max_R_A_error_deg"] < 1e-8
|
|
|
|
|
|
def test_phase_a_time_blocks_do_not_count_overlapping_pairs_as_independent() -> None:
|
|
r_true = so3_exp(np.deg2rad(np.array([1.0, -2.0, 20.0])))
|
|
bias = np.zeros(3)
|
|
pairs = [
|
|
_phase_a_pair("s0", index, r_true, (5.0 + index, 2.0, 1.0), bias)
|
|
for index in range(9)
|
|
]
|
|
selected = select_decorrelated_phase_a_pairs(
|
|
pairs,
|
|
block_s=3.0,
|
|
max_pairs_per_block=1,
|
|
)
|
|
assert len(selected) == 3
|
|
assert all(pair in pairs for pair in selected)
|
|
|
|
|
|
def test_phase_a_planar_motion_is_partial_and_keeps_weak_direction_from_prior() -> None:
|
|
r_true = so3_exp(np.deg2rad(np.array([4.0, -3.0, 31.0])))
|
|
prior = so3_exp(np.deg2rad(np.array([0.2, -0.1, 0.4]))) @ r_true
|
|
biases = {"s0": np.zeros(3), "s1": np.zeros(3)}
|
|
pairs: list[MotionPair] = []
|
|
for session_index, sid in enumerate(biases):
|
|
for index in range(12):
|
|
pairs.append(
|
|
_phase_a_pair(
|
|
sid,
|
|
session_index * 100 + index,
|
|
r_true,
|
|
(0.0, 0.0, 8.0 + index),
|
|
biases[sid],
|
|
)
|
|
)
|
|
result = solve_phase_a_comparison(
|
|
pairs,
|
|
gyro_bias_rad_s_by_session=biases,
|
|
rotation_prior=prior,
|
|
decorrelation_block_s=0.0,
|
|
yaw_std_max_deg=0.5,
|
|
run_leave_one_out=False,
|
|
max_nfev=80,
|
|
)
|
|
assert not result.accepted
|
|
assert result.partial_accepted
|
|
assert result.solution_status == "phase_a_partial_accepted"
|
|
assert result.marginal_observability.precision_rank == 2
|
|
assert result.observable_subspace_with_prior is not None
|
|
assert np.isinf(result.marginal_observability.direction_std_deg[0])
|