278 lines
9.1 KiB
Python
278 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from k1link.perception.contracts import (
|
|
BoundingRegion2D,
|
|
CorridorIntersection,
|
|
GridCell,
|
|
HistorySample,
|
|
LocalObstacleMap,
|
|
MotionState,
|
|
ObjectProposal2D,
|
|
SourceAccounting,
|
|
TemporalObstacle,
|
|
TemporalState,
|
|
ThreatDecision,
|
|
)
|
|
from k1link.perception.graph_validation import validate_threats
|
|
from k1link.perception.threat import (
|
|
DualEvidenceReplayThreatProvider,
|
|
ReplayPose,
|
|
load_replay_threat_profile,
|
|
)
|
|
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
|
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m4-replay-threat-v1.json"
|
|
|
|
|
|
class _Poses:
|
|
def pose_for_frame(self, frame_id: str) -> ReplayPose:
|
|
return ReplayPose(
|
|
frame_id=frame_id,
|
|
position_map_xyz_m=(0.0, 0.0, 0.0),
|
|
orientation_map_from_lidar_xyzw=(0.0, 0.0, 0.0, 1.0),
|
|
)
|
|
|
|
|
|
def _obstacle(
|
|
component_id: str,
|
|
cell: GridCell,
|
|
*,
|
|
motion: MotionState,
|
|
history: tuple[tuple[str, int, tuple[float, float, float]], ...],
|
|
state: TemporalState = TemporalState.CURRENT,
|
|
semantic_hint: str | None = None,
|
|
) -> TemporalObstacle:
|
|
samples = tuple(
|
|
HistorySample(frame_id=frame_id, evidence_time_ns=time_ns, centroid_xyz_m=point)
|
|
for frame_id, time_ns, point in history
|
|
)
|
|
current = samples[-1]
|
|
return TemporalObstacle(
|
|
component_id=component_id,
|
|
identity_scope="ephemeral",
|
|
state=state,
|
|
ttl_ns=750_000_000,
|
|
last_hit_ns=current.evidence_time_ns,
|
|
age_ns=0 if state is TemporalState.CURRENT else 100_000_000,
|
|
association_basis="test-spatial-support",
|
|
history=samples,
|
|
cells=() if state is TemporalState.EXPIRED else (cell,),
|
|
coordinate_frame=None if state is TemporalState.EXPIRED else "map",
|
|
last_centroid_xyz_m=None if state is TemporalState.EXPIRED else current.centroid_xyz_m,
|
|
motion=MotionState.UNKNOWN if state is not TemporalState.CURRENT else motion,
|
|
motion_confidence=(
|
|
0.0
|
|
if state is not TemporalState.CURRENT or motion is MotionState.UNKNOWN
|
|
else 1.0
|
|
),
|
|
motion_reason=(
|
|
"stale-support"
|
|
if state is not TemporalState.CURRENT
|
|
else "bounded-map-history-moving"
|
|
if motion is MotionState.MOVING
|
|
else "bounded-map-history-stationary"
|
|
if motion is MotionState.STATIONARY
|
|
else "insufficient-history"
|
|
),
|
|
semantic_hint=semantic_hint,
|
|
)
|
|
|
|
|
|
def _proposal(frame_id: str = "frame-000002") -> ObjectProposal2D:
|
|
return ObjectProposal2D(
|
|
proposal_id="proposal-camera-only",
|
|
source_id="RAVNOVES00",
|
|
frame_id=frame_id,
|
|
region=BoundingRegion2D(10.0, 10.0, 20.0, 20.0),
|
|
objectness=0.8,
|
|
provider_id="test-detector/v1",
|
|
model_id="test-model/v1",
|
|
preprocess_id="test-preprocess/v1",
|
|
semantic_hint="person",
|
|
)
|
|
|
|
|
|
def _map(
|
|
*,
|
|
occupied: tuple[TemporalObstacle, ...] = (),
|
|
unknown: tuple[TemporalObstacle, ...] = (),
|
|
camera: tuple[ObjectProposal2D, ...] = (),
|
|
frame_id: str = "frame-000002",
|
|
) -> LocalObstacleMap:
|
|
return LocalObstacleMap(
|
|
source_id="RAVNOVES00",
|
|
session_id="20260720T065719Z_viewer_live",
|
|
frame_id=frame_id,
|
|
graph_id="reference-perception-graph/v1",
|
|
generated_monotonic_ns=0,
|
|
output_age_ns=0,
|
|
occupied=occupied,
|
|
unknown=unknown,
|
|
camera_uncertainty=camera,
|
|
accounting=SourceAccounting(1, 1, 0, 0),
|
|
)
|
|
|
|
|
|
def test_replay_threat_profile_freezes_virtual_authority_and_dual_evidence_policy() -> None:
|
|
profile = load_replay_threat_profile(PROFILE_PATH)
|
|
|
|
assert profile.rig.body_length_m == 1.0
|
|
assert profile.rig.body_width_m == 0.6
|
|
assert profile.rig.nominal_sensor_height_m == 1.25
|
|
assert profile.corridor.forward_length_m == 8.0
|
|
assert profile.calibration_content_sha256 == (
|
|
"05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
|
)
|
|
|
|
|
|
def test_static_crossing_approaching_and_geometry_only_critical_cases_are_never_safe() -> None:
|
|
provider = DualEvidenceReplayThreatProvider(
|
|
pose_resolver=_Poses(),
|
|
profile=load_replay_threat_profile(PROFILE_PATH),
|
|
)
|
|
current_frame = "frame-000002"
|
|
critical = (
|
|
_obstacle(
|
|
"static-in-corridor",
|
|
GridCell(6, 0, 0),
|
|
motion=MotionState.STATIONARY,
|
|
history=(
|
|
("frame-000000", 0, (2.925, 0.225, 0.225)),
|
|
(current_frame, 300_000_000, (2.925, 0.225, 0.225)),
|
|
),
|
|
),
|
|
_obstacle(
|
|
"crossing",
|
|
GridCell(6, 3, 0),
|
|
motion=MotionState.MOVING,
|
|
history=(
|
|
("frame-000000", 0, (2.925, 2.575, 0.225)),
|
|
(current_frame, 300_000_000, (2.925, 1.575, 0.225)),
|
|
),
|
|
),
|
|
_obstacle(
|
|
"approaching",
|
|
GridCell(9, 0, 0),
|
|
motion=MotionState.MOVING,
|
|
history=(
|
|
("frame-000000", 0, (6.275, 0.225, 0.225)),
|
|
(current_frame, 300_000_000, (4.275, 0.225, 0.225)),
|
|
),
|
|
semantic_hint="car",
|
|
),
|
|
_obstacle(
|
|
"geometry-only",
|
|
GridCell(4, 0, 0),
|
|
motion=MotionState.STATIONARY,
|
|
history=(
|
|
("frame-000000", 0, (2.025, 0.225, 0.225)),
|
|
(current_frame, 300_000_000, (2.025, 0.225, 0.225)),
|
|
),
|
|
semantic_hint=None,
|
|
),
|
|
)
|
|
|
|
result = provider.assess(_map(occupied=critical))
|
|
|
|
assert {item.decision for item in result} == {ThreatDecision.THREAT}
|
|
assert all(item.corridor_intersection is CorridorIntersection.INTERSECTS for item in result)
|
|
assert (
|
|
next(item for item in result if item.component_id == "approaching").ttc_seconds
|
|
is not None
|
|
)
|
|
assert "geometry-only-evidence" in next(
|
|
item for item in result if item.component_id == "geometry-only"
|
|
).reason_codes
|
|
|
|
|
|
def test_receding_and_static_outside_are_clear_but_incomplete_evidence_is_unknown() -> None:
|
|
provider = DualEvidenceReplayThreatProvider(
|
|
pose_resolver=_Poses(),
|
|
profile=load_replay_threat_profile(PROFILE_PATH),
|
|
)
|
|
current_frame = "frame-000002"
|
|
clear = (
|
|
_obstacle(
|
|
"static-outside",
|
|
GridCell(6, 7, 0),
|
|
motion=MotionState.STATIONARY,
|
|
history=(
|
|
("frame-000000", 0, (2.925, 3.375, 0.225)),
|
|
(current_frame, 300_000_000, (2.925, 3.375, 0.225)),
|
|
),
|
|
),
|
|
_obstacle(
|
|
"receding-behind",
|
|
GridCell(-5, 0, 0),
|
|
motion=MotionState.MOVING,
|
|
history=(
|
|
("frame-000000", 0, (-1.025, 0.225, 0.225)),
|
|
(current_frame, 300_000_000, (-2.025, 0.225, 0.225)),
|
|
),
|
|
),
|
|
)
|
|
incomplete = _obstacle(
|
|
"unknown-motion",
|
|
GridCell(6, 7, 0),
|
|
motion=MotionState.UNKNOWN,
|
|
history=((current_frame, 300_000_000, (2.925, 3.375, 0.225)),),
|
|
)
|
|
held = _obstacle(
|
|
"occluded-held",
|
|
GridCell(6, 0, 0),
|
|
motion=MotionState.UNKNOWN,
|
|
history=((current_frame, 300_000_000, (2.925, 0.225, 0.225)),),
|
|
state=TemporalState.HELD,
|
|
)
|
|
obstacle_map = _map(
|
|
occupied=(*clear, incomplete),
|
|
unknown=(held,),
|
|
camera=(_proposal(),),
|
|
)
|
|
|
|
result = provider.assess(obstacle_map)
|
|
by_id = {item.component_id: item for item in result}
|
|
|
|
assert by_id["static-outside"].decision is ThreatDecision.NOT_THREAT
|
|
assert by_id["receding-behind"].decision is ThreatDecision.NOT_THREAT
|
|
assert by_id["unknown-motion"].decision is ThreatDecision.UNKNOWN
|
|
assert by_id["occluded-held"].decision is ThreatDecision.UNKNOWN
|
|
assert by_id["proposal-camera-only"].decision is ThreatDecision.UNKNOWN
|
|
assert by_id["proposal-camera-only"].closest_approach_m is None
|
|
validate_threats(obstacle_map, result)
|
|
|
|
|
|
def test_semantic_hint_and_ephemeral_component_name_do_not_change_threat_geometry() -> None:
|
|
provider = DualEvidenceReplayThreatProvider(
|
|
pose_resolver=_Poses(),
|
|
profile=load_replay_threat_profile(PROFILE_PATH),
|
|
)
|
|
history = (
|
|
("frame-000000", 0, (2.925, 0.225, 0.225)),
|
|
("frame-000002", 300_000_000, (2.925, 0.225, 0.225)),
|
|
)
|
|
first = _obstacle(
|
|
"ephemeral-a",
|
|
GridCell(6, 0, 0),
|
|
motion=MotionState.STATIONARY,
|
|
history=history,
|
|
semantic_hint="car",
|
|
)
|
|
second = _obstacle(
|
|
"ephemeral-b",
|
|
GridCell(6, 0, 0),
|
|
motion=MotionState.STATIONARY,
|
|
history=history,
|
|
semantic_hint=None,
|
|
)
|
|
|
|
values = provider.assess(_map(occupied=(first, second)))
|
|
|
|
assert values[0].decision == values[1].decision
|
|
assert values[0].corridor_intersection == values[1].corridor_intersection
|
|
assert values[0].relative_speed_mps == values[1].relative_speed_mps
|
|
assert values[0].closest_approach_m == values[1].closest_approach_m
|
|
assert values[0].ttc_seconds == values[1].ttc_seconds
|