feat(perception): add E34 temporal occupied layer
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.routing import APIRoute
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import k1link.web.advanced_laboratory_api as advanced_api
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
|
||||
|
||||
@@ -23,7 +26,7 @@ def _endpoint(router: APIRouter, path: str) -> object:
|
||||
def test_advanced_catalogs_are_empty_when_not_configured() -> None:
|
||||
router = build_advanced_laboratory_router()
|
||||
|
||||
for name in ("e31", "e32", "e33"):
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog == {
|
||||
@@ -42,18 +45,21 @@ def test_advanced_catalogs_fail_closed_on_incomplete_results(
|
||||
e31 = tmp_path / "e31"
|
||||
e32 = tmp_path / "e32"
|
||||
e33 = tmp_path / "e33"
|
||||
for root in (e31, e32, e33):
|
||||
e34 = tmp_path / "e34"
|
||||
for root in (e31, e32, e33, e34):
|
||||
root.mkdir()
|
||||
(e31 / f"e31-source-qualification-{'1' * 64}").mkdir()
|
||||
(e32 / f"e32-track-geometry-{'2' * 64}").mkdir()
|
||||
(e33 / f"e33-worker-shadow-{'3' * 64}").mkdir()
|
||||
(e34 / f"e34-temporal-occupied-{'4' * 64}").mkdir()
|
||||
router = build_advanced_laboratory_router(
|
||||
e31_root_provider=lambda: e31,
|
||||
e32_root_provider=lambda: e32,
|
||||
e33_root_provider=lambda: e33,
|
||||
e34_root_provider=lambda: e34,
|
||||
)
|
||||
|
||||
for name in ("e31", "e32", "e33"):
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog["configured"] is True
|
||||
@@ -77,3 +83,129 @@ def test_advanced_catalog_does_not_follow_a_configured_symlink(
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
|
||||
assert catalog["configured"] is False
|
||||
|
||||
|
||||
def test_e34_catalog_projects_only_accepted_read_only_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
result_id = f"e34-temporal-occupied-{'4' * 64}"
|
||||
root = tmp_path / "e34"
|
||||
candidate = root / result_id
|
||||
candidate.mkdir(parents=True)
|
||||
(candidate / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
authority = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
result_id=result_id,
|
||||
accepted=True,
|
||||
manifest={
|
||||
"created_at_utc": "2026-07-27T13:28:30Z",
|
||||
"acceptance_state": (
|
||||
"accepted-bounded-occupied-unknown-temporal-layer"
|
||||
),
|
||||
"identity": {
|
||||
"source_session_id": "source-session",
|
||||
"e32_result_id": f"e32-track-geometry-{'2' * 64}",
|
||||
"e33_result_id": f"e33-worker-shadow-{'3' * 64}",
|
||||
"pipeline": "lidar-local-map/short-ttl-hit-only/v1",
|
||||
"coordinate_frame": "map",
|
||||
"profile": {
|
||||
"profile_id": "e34-profile/v1",
|
||||
"layer": {
|
||||
"voxel_size_m": 0.45,
|
||||
"occupied_ttl_seconds": 0.75,
|
||||
"maximum_active_components": 256,
|
||||
"maximum_cells_per_component": 4096,
|
||||
},
|
||||
"association": {
|
||||
"geometry_maximum_gap_seconds": 0.35,
|
||||
"geometry_maximum_centroid_distance_m": 0.9,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
report={
|
||||
"metrics": {
|
||||
"frames": {
|
||||
"source": 4489,
|
||||
"processed": 4489,
|
||||
"with_current_layer": 3928,
|
||||
"held_only": 540,
|
||||
"unknown_empty": 21,
|
||||
},
|
||||
"components": {
|
||||
"created": 4912,
|
||||
"camera_created": 1027,
|
||||
"geometry_created": 3885,
|
||||
"geometry_reassociated_components": 2366,
|
||||
"exact_camera_associations": 5168,
|
||||
"geometry_spatial_reassociations": 17433,
|
||||
"held_publications": 39664,
|
||||
"expired": 4895,
|
||||
"peak_active": 37,
|
||||
"maximum_observed_span_seconds": 13.394,
|
||||
"continuity_fraction": 0.821466,
|
||||
"geometry_only_reassociation_fraction": 0.81776,
|
||||
},
|
||||
"occupancy": {
|
||||
"e32_current_point_rows": 2119302,
|
||||
"e34_consumed_current_point_rows": 2119302,
|
||||
"current_cell_rows": 834478,
|
||||
"held_cell_rows": 531581,
|
||||
"stored_cell_rows": 1366059,
|
||||
"free_cell_rows": 0,
|
||||
"peak_cells_per_component": 324,
|
||||
},
|
||||
"aging": {
|
||||
"maximum_held_age_seconds": 0.75,
|
||||
"maximum_expiry_delay_seconds": 0.0,
|
||||
"maximum_replay_materialization_delay_seconds": 0.283,
|
||||
},
|
||||
"map_frame": {"jump_candidates": 0},
|
||||
"runtime": {
|
||||
"build_elapsed_ms": 4317.0,
|
||||
"frame_processing_ms": {"p95": 1.483},
|
||||
},
|
||||
},
|
||||
"acceptance": {"accepted": True, "requirements": {}},
|
||||
"decision": {"next_gate": "A8/E35"},
|
||||
"authority": authority,
|
||||
},
|
||||
review={
|
||||
"schema_version": (
|
||||
"missioncore.e34-temporal-review-timeline/v1"
|
||||
),
|
||||
"result_id": result_id,
|
||||
"frames": [],
|
||||
},
|
||||
)
|
||||
|
||||
def fake_read(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> SimpleNamespace:
|
||||
assert root_text == str(candidate.resolve())
|
||||
assert signature
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(advanced_api, "_read_e34_cached", fake_read)
|
||||
router = build_advanced_laboratory_router(
|
||||
e34_root_provider=lambda: root,
|
||||
)
|
||||
|
||||
route = _endpoint(router, "/api/v1/laboratory/e34/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
|
||||
assert catalog["candidate_total"] == 1
|
||||
assert catalog["invalid_total"] == 0
|
||||
item = catalog["items"][0]
|
||||
assert item["status"] == (
|
||||
"accepted-bounded-occupied-unknown-temporal-layer"
|
||||
)
|
||||
assert item["metrics"]["processed_frames"] == 4489
|
||||
assert item["metrics"]["free_cell_rows"] == 0
|
||||
assert item["authority"] == authority
|
||||
assert item["access"] == "read-only"
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.compute.temporal_occupied_layer import (
|
||||
TemporalLayerConfig,
|
||||
TemporalOccupiedLayer,
|
||||
TemporalOccupiedLayerError,
|
||||
)
|
||||
from k1link.compute.track_geometry import (
|
||||
PointSlab,
|
||||
TrackGeometry,
|
||||
TrackGeometryCurrentness,
|
||||
TrackGeometryEvidenceState,
|
||||
TrackGeometryFrame,
|
||||
TrackGeometryMetricBasis,
|
||||
TrackGeometryOwnerKind,
|
||||
TrackGeometrySourceBinding,
|
||||
)
|
||||
|
||||
|
||||
def _config(**changes: object) -> TemporalLayerConfig:
|
||||
values: dict[str, object] = {
|
||||
"voxel_size_m": 0.45,
|
||||
"occupied_ttl_seconds": 0.75,
|
||||
"maximum_active_components": 256,
|
||||
"maximum_cells_per_component": 4096,
|
||||
"geometry_maximum_gap_seconds": 0.35,
|
||||
"geometry_maximum_centroid_distance_m": 0.9,
|
||||
"geometry_minimum_voxel_overlap_fraction": 0.05,
|
||||
"geometry_neighbor_radius_cells": 1,
|
||||
"jump_maximum_adjacent_gap_seconds": 0.25,
|
||||
"jump_minimum_matched_components": 4,
|
||||
"jump_minimum_median_displacement_m": 1.5,
|
||||
"jump_minimum_p25_displacement_m": 0.9,
|
||||
}
|
||||
values.update(changes)
|
||||
return TemporalLayerConfig(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _binding() -> TrackGeometrySourceBinding:
|
||||
return TrackGeometrySourceBinding(
|
||||
source_pack_id="e10-lidar-pack-" + "a" * 64,
|
||||
source_session_id="source-session",
|
||||
representation_profile_id="representation-profile/v1",
|
||||
e31_qualification_id="e31-source-qualification-" + "b" * 64,
|
||||
calibration_sha256="c" * 64,
|
||||
coordinate_frame="map",
|
||||
time_basis="source-time",
|
||||
selected_offset_ms=0,
|
||||
)
|
||||
|
||||
|
||||
def _frame(
|
||||
*,
|
||||
frame_index: int,
|
||||
seconds: float,
|
||||
owners: list[
|
||||
tuple[
|
||||
str,
|
||||
TrackGeometryOwnerKind,
|
||||
list[list[float]],
|
||||
int | None,
|
||||
]
|
||||
],
|
||||
source_available: bool = True,
|
||||
unknown_camera_owner: str | None = None,
|
||||
) -> TrackGeometryFrame:
|
||||
owner_keys = tuple(item[0] for item in owners)
|
||||
source_indices: list[int] = []
|
||||
points: list[list[float]] = []
|
||||
owner_indices: list[int] = []
|
||||
geometries: list[TrackGeometry] = []
|
||||
for owner_index, (owner_key, owner_kind, owner_points, track_id) in enumerate(
|
||||
owners
|
||||
):
|
||||
row_start = len(points)
|
||||
points.extend(owner_points)
|
||||
source_indices.extend(range(row_start, row_start + len(owner_points)))
|
||||
owner_indices.extend([owner_index] * len(owner_points))
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=owner_key,
|
||||
owner_kind=owner_kind,
|
||||
evidence_state=(
|
||||
TrackGeometryEvidenceState.AGREE
|
||||
if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
else TrackGeometryEvidenceState.GEOMETRY_ONLY
|
||||
),
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=("accepted-hit-backed-support",),
|
||||
semantic_track_id=track_id,
|
||||
semantic_label=(
|
||||
"object"
|
||||
if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
else None
|
||||
),
|
||||
bbox_xyxy=(
|
||||
(1.0, 1.0, 2.0, 2.0)
|
||||
if owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
else None
|
||||
),
|
||||
range_m=1.0,
|
||||
)
|
||||
)
|
||||
if unknown_camera_owner is not None:
|
||||
track_id = int(unknown_camera_owner.split(":")[1])
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=unknown_camera_owner,
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.UNAVAILABLE,
|
||||
reason_codes=("camera-without-current-points",),
|
||||
semantic_track_id=track_id,
|
||||
semantic_label="object",
|
||||
bbox_xyxy=(1.0, 1.0, 2.0, 2.0),
|
||||
)
|
||||
)
|
||||
point_array = (
|
||||
np.asarray(points, dtype="<f4")
|
||||
if points
|
||||
else np.empty((0, 3), dtype="<f4")
|
||||
)
|
||||
return TrackGeometryFrame(
|
||||
binding=_binding(),
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
session_seconds=seconds,
|
||||
source_available=source_available,
|
||||
point_slab=PointSlab(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
source_point_count=len(points),
|
||||
coordinate_frame="map",
|
||||
owner_keys=owner_keys,
|
||||
source_indices=np.asarray(source_indices, dtype="<i8"),
|
||||
points_xyz_m=point_array,
|
||||
owner_indices=np.asarray(owner_indices, dtype="<u4"),
|
||||
),
|
||||
geometries=tuple(geometries),
|
||||
)
|
||||
|
||||
|
||||
def test_temporal_layer_holds_unknown_then_expires_without_publishing_cells() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
current = layer.update(
|
||||
_frame(
|
||||
frame_index=0,
|
||||
seconds=0.0,
|
||||
owners=[
|
||||
(
|
||||
"track:7",
|
||||
TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
[[1.0, 2.0, 0.5], [1.1, 2.0, 0.5]],
|
||||
7,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
held = layer.update(
|
||||
_frame(
|
||||
frame_index=1,
|
||||
seconds=0.5,
|
||||
owners=[],
|
||||
source_available=False,
|
||||
)
|
||||
)
|
||||
expired = layer.update(
|
||||
_frame(
|
||||
frame_index=2,
|
||||
seconds=0.9,
|
||||
owners=[],
|
||||
source_available=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert current.document["current"][0]["occupancy_state"] == "occupied"
|
||||
assert held.document["held"][0]["occupancy_state"] == "unknown"
|
||||
assert held.document["held"][0]["cell_row_count"] > 0
|
||||
assert expired.document["expired"][0]["occupancy_state"] == "unknown"
|
||||
assert expired.document["expired"][0]["cell_row_count"] == 0
|
||||
assert expired.document["expired"][0]["expiration_event"] == {
|
||||
"deadline_seconds": 0.75,
|
||||
"emitted_at_seconds": 0.75,
|
||||
"emission_delay_seconds": 0.0,
|
||||
"replay_materialized_at_seconds": 0.9,
|
||||
"replay_materialization_delay_seconds": pytest.approx(0.15),
|
||||
"clock": "independent-layer-deadline",
|
||||
}
|
||||
assert expired.cell_rows.shape == (0, 3)
|
||||
assert layer.active_component_count == 0
|
||||
|
||||
|
||||
def test_geometry_only_components_reassociate_without_inventing_semantics() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
first = layer.update(
|
||||
_frame(
|
||||
frame_index=0,
|
||||
seconds=0.0,
|
||||
owners=[
|
||||
(
|
||||
"geometry:0",
|
||||
TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
[[1.0, 1.0, 0.5], [1.2, 1.0, 0.5]],
|
||||
None,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
second = layer.update(
|
||||
_frame(
|
||||
frame_index=1,
|
||||
seconds=0.1,
|
||||
owners=[
|
||||
(
|
||||
"geometry:9",
|
||||
TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
[[1.1, 1.0, 0.5], [1.3, 1.0, 0.5]],
|
||||
None,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert first.document["current"][0]["temporal_id"] == second.document[
|
||||
"current"
|
||||
][0]["temporal_id"]
|
||||
assert second.document["current"][0]["association_reason"] == (
|
||||
"geometry-spatial-reassociation"
|
||||
)
|
||||
assert second.document["current"][0]["semantic_provenance"] == {
|
||||
"owner": None,
|
||||
"track_ids": [],
|
||||
"labels": [],
|
||||
"class_inferred_by_e34": False,
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_without_current_points_cannot_create_occupied_cells() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
projection = layer.update(
|
||||
_frame(
|
||||
frame_index=0,
|
||||
seconds=0.0,
|
||||
owners=[],
|
||||
unknown_camera_owner="track:11",
|
||||
)
|
||||
)
|
||||
|
||||
assert projection.document["layer_state"] == "unknown"
|
||||
assert projection.document["current"] == []
|
||||
assert projection.document["input"]["unknown_without_current_points"] == 1
|
||||
assert projection.cell_rows.shape == (0, 3)
|
||||
assert layer.active_component_count == 0
|
||||
|
||||
|
||||
def test_temporal_layer_detects_supported_global_map_frame_jump() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
first = [
|
||||
(
|
||||
f"track:{track_id}",
|
||||
TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
[[float(track_id), 0.0, 0.5]],
|
||||
track_id,
|
||||
)
|
||||
for track_id in range(1, 5)
|
||||
]
|
||||
shifted = [
|
||||
(
|
||||
f"track:{track_id}",
|
||||
TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
[[float(track_id) + 2.0, 0.0, 0.5]],
|
||||
track_id,
|
||||
)
|
||||
for track_id in range(1, 5)
|
||||
]
|
||||
layer.update(_frame(frame_index=0, seconds=0.0, owners=first))
|
||||
projection = layer.update(
|
||||
_frame(frame_index=1, seconds=0.1, owners=shifted)
|
||||
)
|
||||
|
||||
assert projection.document["map_frame_jump"]["candidate"] is True
|
||||
assert projection.document["map_frame_jump"][
|
||||
"matched_component_count"
|
||||
] == 4
|
||||
|
||||
|
||||
def test_temporal_layer_rejects_incoherent_nearest_neighbor_jump() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
first = [
|
||||
(
|
||||
f"geometry:{index}",
|
||||
TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
[[float(index) * 5.0, 0.0, 0.5]],
|
||||
None,
|
||||
)
|
||||
for index in range(4)
|
||||
]
|
||||
incoherent = [
|
||||
(
|
||||
f"geometry:{index}",
|
||||
TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
[[x, y, 0.5]],
|
||||
None,
|
||||
)
|
||||
for index, (x, y) in enumerate(
|
||||
((2.0, 0.0), (8.0, 2.0), (13.0, -3.0), (25.0, 4.0))
|
||||
)
|
||||
]
|
||||
layer.update(_frame(frame_index=0, seconds=0.0, owners=first))
|
||||
projection = layer.update(
|
||||
_frame(frame_index=1, seconds=0.1, owners=incoherent)
|
||||
)
|
||||
|
||||
assert projection.document["map_frame_jump"]["candidate"] is False
|
||||
assert projection.document["map_frame_jump"]["reason"] == (
|
||||
"no-coherent-global-translation"
|
||||
)
|
||||
|
||||
|
||||
def test_temporal_layer_fails_closed_instead_of_truncating_cells() -> None:
|
||||
layer = TemporalOccupiedLayer(_config(maximum_cells_per_component=1))
|
||||
|
||||
with pytest.raises(
|
||||
TemporalOccupiedLayerError,
|
||||
match="cell bound exceeded",
|
||||
):
|
||||
layer.update(
|
||||
_frame(
|
||||
frame_index=0,
|
||||
seconds=0.0,
|
||||
owners=[
|
||||
(
|
||||
"geometry:0",
|
||||
TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
|
||||
None,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_temporal_layer_rejects_non_monotonic_replay() -> None:
|
||||
layer = TemporalOccupiedLayer(_config())
|
||||
layer.update(_frame(frame_index=0, seconds=0.0, owners=[]))
|
||||
|
||||
with pytest.raises(
|
||||
TemporalOccupiedLayerError,
|
||||
match="frame order changed",
|
||||
):
|
||||
layer.update(_frame(frame_index=2, seconds=0.1, owners=[]))
|
||||
Reference in New Issue
Block a user