NODEDC_MISSION_CORE/tests/test_lidar_replay.py

265 lines
8.8 KiB
Python

from __future__ import annotations
import json
import struct
from pathlib import Path
import lz4.block
import pytest
from fastapi import APIRouter
from fastapi.routing import APIRoute
from k1link.compute import (
K1_LIDAR_PACK_V2_PROFILE,
LidarPipelineStage,
LidarReadiness,
LidarReplayError,
LidarReplayPackV2,
assess_lidar_profile,
build_lidar_replay_pack_v2,
verify_lidar_replay_equivalence,
)
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.web.lidar_api import build_lidar_router
def _varint(value: int) -> bytes:
encoded = bytearray()
while value > 0x7F:
encoded.append((value & 0x7F) | 0x80)
value >>= 7
encoded.append(value)
return bytes(encoded)
def _key(number: int, wire_type: int) -> bytes:
return _varint((number << 3) | wire_type)
def _uint(number: int, value: int) -> bytes:
return _key(number, 0) + _varint(value)
def _sint(number: int, value: int) -> bytes:
zigzag = (value << 1) ^ (value >> 63)
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
def _bytes(number: int, value: bytes) -> bytes:
return _key(number, 2) + _varint(len(value)) + value
def _fixed32(number: int, value: float) -> bytes:
return _key(number, 5) + struct.pack("<f", value)
def _fixed64(number: int, value: float) -> bytes:
return _key(number, 1) + struct.pack("<d", value)
def _header(sequence: int, stamp: int, scaler: int = 1000) -> bytes:
return b"".join(
(
_uint(1, sequence),
_sint(2, stamp),
_sint(3, scaler),
_bytes(4, b"device-redacted"),
_bytes(5, b"session-redacted"),
)
)
def _pcl_payload(sequence: int, stamp: int, first_rgbi: int) -> bytes:
point_1 = (
_sint(1, 1000 * sequence)
+ _sint(2, -2000)
+ _sint(3, 500)
+ _uint(4, first_rgbi)
)
point_2 = (
_sint(1, -250)
+ _sint(2, sequence)
+ _sint(3, 4000)
+ _uint(4, 0xAABBCC09)
)
report = (
_bytes(1, _header(sequence, stamp))
+ _bytes(2, point_1)
+ _bytes(2, point_2)
)
compressed = lz4.block.compress(report, store_size=False)
return _uint(3, len(report)) + _bytes(4, compressed)
def _pose_payload(sequence: int, stamp: int) -> bytes:
position = (
_fixed64(1, float(sequence))
+ _fixed64(2, -2.5)
+ _fixed64(3, 3.75)
)
orientation = (
_fixed64(1, 0.0)
+ _fixed64(2, 0.0)
+ _fixed64(3, 0.0)
+ _fixed64(4, 1.0)
)
pose = _bytes(1, position) + _bytes(2, orientation)
stamped = _sint(1, stamp + 1) + _bytes(2, pose)
return (
_bytes(1, _header(sequence, stamp))
+ _bytes(2, stamped)
+ _fixed32(3, 12.5)
+ _fixed32(4, 0.001)
)
def _capture(tmp_path: Path) -> Path:
session = tmp_path / "session-001"
root = session / "captures" / "mqtt_live"
root.mkdir(parents=True)
messages = [
("lixel/application/report/lio_pcl", _pcl_payload(10, 1_000, 0x11223344)),
("lixel/application/report/lio_pose", _pose_payload(20, 1_010)),
("lixel/application/report/lio_pcl", _pcl_payload(11, 2_000, 0x55667788)),
("lixel/application/report/lio_pose", _pose_payload(21, 2_010)),
]
raw = bytearray(RAW_MAGIC)
metadata: list[str] = []
for sequence, (topic, payload) in enumerate(messages, start=1):
topic_bytes = topic.encode()
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
raw.extend(topic_bytes)
raw.extend(payload)
metadata.append(
json.dumps(
{
"schema_version": 1,
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": 1_000_000_000 + sequence * 10_000_000,
"received_monotonic_ns": 5_000_000_000 + sequence * 10_000_000,
},
separators=(",", ":"),
)
)
capture = root / "mqtt.raw.k1mqtt"
capture.write_bytes(raw)
(root / "mqtt.metadata.jsonl").write_text(
"\n".join(metadata) + "\n",
encoding="utf-8",
)
(root / "mqtt.timeline.origin.json").write_text(
json.dumps(
{
"schema_version": 1,
"started_at_epoch_ns": 1_000_000_000,
"started_monotonic_ns": 5_000_000_000,
}
),
encoding="utf-8",
)
return capture
def _endpoint(router: APIRouter, path: str) -> object:
for route in router.routes:
if isinstance(route, APIRoute) and route.path == path and "GET" in route.methods:
return route.endpoint
raise AssertionError(f"GET {path} route is missing")
def test_lidar_replay_v2_retains_fields_and_passes_equivalence(tmp_path: Path) -> None:
capture = _capture(tmp_path)
output = build_lidar_replay_pack_v2(capture, tmp_path / "packs")
pack = LidarReplayPackV2(output)
try:
assert pack.identity["session_id"] == "session-001"
assert pack.identity["lidar_evidence_profile"] == K1_LIDAR_PACK_V2_PROFILE.to_dict()
assert pack.point_frame_count == 2
assert pack.pose_frame_count == 2
assert pack.point_count == 4
point = pack.point_frame(0)
assert point.capture_sequence == 1
assert point.received_at_epoch_ns == 1_010_000_000
assert point.received_monotonic_ns == 5_010_000_000
assert point.header_seq == 10
assert point.header_stamp == 1_000
assert point.scaler == 1_000
assert point.raw_xyz.tolist() == [[10_000, -2_000, 500], [-250, 10, 4_000]]
assert point.xyz_map.tolist() == [[10.0, -2.0, 0.5], [-0.25, 0.01, 4.0]]
assert point.rgbi.tolist() == [0x11223344, 0xAABBCC09]
assert point.intensity.tolist() == [0x44, 0x09]
assert point.decoded_view().intensities == bytes((0x44, 0x09))
pose = pack.pose_frame(0)
assert pose.capture_sequence == 2
assert pose.header_seq == 20
assert pose.pose_stamp == 1_011
assert pose.position_map == (20.0, -2.5, 3.75)
assert pose.decoded_view().frame_id == "map"
assert pack.quality["field_retention"]["raw_rgbi"] is True
assert pack.quality["field_retention"]["host_monotonic_ns"] is True
assert pack.quality["sensor_range_m"]["sample_count"] == 0
assert pack.quality["sensor_range_status"].startswith("unavailable-")
assert pack.quality["pose_binding"]["coverage_fraction"] == pytest.approx(1.0)
assert pack.equivalence["status"] == "passed"
assert pack.equivalence["array_mismatches"] == 0
rerun = verify_lidar_replay_equivalence(capture, pack)
assert rerun["status"] == "passed"
finally:
pack.close()
assert build_lidar_replay_pack_v2(capture, tmp_path / "packs") == output
def test_lidar_replay_v2_unblocks_detector_input_but_not_mapping() -> None:
assessments = {
item.stage: item for item in assess_lidar_profile(K1_LIDAR_PACK_V2_PROFILE)
}
assert (
assessments[LidarPipelineStage.LIDAR_3D_DETECTION].readiness
is LidarReadiness.DEGRADED
)
assert (
assessments[LidarPipelineStage.LIDAR_INERTIAL_SLAM].readiness
is LidarReadiness.BLOCKED
)
assert (
assessments[LidarPipelineStage.NVIDIA_NVBLOX].readiness
is LidarReadiness.BLOCKED
)
def test_lidar_replay_v2_fails_closed_without_exact_host_timing(
tmp_path: Path,
) -> None:
capture = _capture(tmp_path)
capture.with_name("mqtt.metadata.jsonl").unlink()
with pytest.raises(LidarReplayError, match="exact host timing"):
build_lidar_replay_pack_v2(capture, tmp_path / "packs")
def test_lidar_api_exposes_path_free_quality_and_equivalence(tmp_path: Path) -> None:
capture = _capture(tmp_path)
root = tmp_path / "packs"
output = build_lidar_replay_pack_v2(capture, root)
router = build_lidar_router(root_provider=lambda: root)
catalog_route = _endpoint(router, "/api/v1/lidar/replay-packs")
detail_route = _endpoint(router, "/api/v1/lidar/replay-packs/{pack_id}")
catalog = catalog_route(limit=20) # type: ignore[operator]
detail = detail_route(pack_id=output.name) # type: ignore[operator]
assert catalog["schema_version"] == "missioncore.lidar-replay-pack-catalog/v1"
assert catalog["valid_total"] == 1
assert catalog["invalid_total"] == 0
assert catalog["items"][0]["equivalence_status"] == "passed"
assert detail["pack"]["pack_id"] == output.name
assert detail["quality"]["field_retention"]["raw_rgbi"] is True
assert detail["equivalence"]["status"] == "passed"
assert detail["access"] == "read-only"
serialized = repr({"catalog": catalog, "detail": detail})
assert str(tmp_path) not in serialized