feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("scipy")
|
||||
from k1link.reconstruction.closure import (
|
||||
ClosurePolicy,
|
||||
ClosureUnavailable,
|
||||
acquire_closure,
|
||||
review_acceptance,
|
||||
)
|
||||
|
||||
|
||||
def source():
|
||||
t = np.arange(121.0)
|
||||
ids = np.repeat(np.arange(len(t)), 100)
|
||||
pts = np.random.default_rng(72).normal(size=(len(ids), 3))
|
||||
pts[:, 0] = ids / 10
|
||||
return dict(
|
||||
frames=np.column_stack([t, t, t * 100, np.full(len(t), 100)]),
|
||||
poses=np.column_stack([t, np.zeros((len(t), 3))]),
|
||||
frame_distance=t * 2,
|
||||
sample_frame=ids,
|
||||
sample_points=pts,
|
||||
heldout=(t % 10 >= 4) & (t % 10 < 6),
|
||||
)
|
||||
|
||||
|
||||
def exact(reference, query, seed, *, acquisition):
|
||||
if acquisition:
|
||||
np.testing.assert_array_equal(seed, np.eye(4))
|
||||
seed = seed.copy()
|
||||
seed[2, 3] = 4.2
|
||||
return dict(status="candidate", T_reference_query=seed.tolist())
|
||||
|
||||
|
||||
def test_complete_search_selects_support_without_heldout_or_endpoint_seed():
|
||||
data = source()
|
||||
data["poses"][-1, 1:4] = [5, 0, 2] # physical overshoot is not a constraint
|
||||
original = data["sample_points"].copy()
|
||||
calls = []
|
||||
|
||||
def register(a, b, seed, *, acquisition):
|
||||
for cloud in (a, b):
|
||||
frame_ids = np.rint(cloud[:, 0] * 10).astype(int)
|
||||
assert not data["heldout"][frame_ids].any()
|
||||
assert not set(a[:, 0]) & set(b[:, 0])
|
||||
calls.append(acquisition)
|
||||
return exact(a, b, seed, acquisition=acquisition)
|
||||
|
||||
link, report = acquire_closure(data, register)
|
||||
assert report["complete"] and len(report["attempts"]) == 12
|
||||
selected = report["attempts"][report["selected_attempt"]]
|
||||
assert (selected["reference_s"], selected["query_s"]) == (20, 5)
|
||||
assert calls == [True, False] * 12
|
||||
assert not report["endpoint_constraint"]
|
||||
assert link.T_reference_query[2, 3] == 4.2
|
||||
np.testing.assert_array_equal(data["sample_points"], original)
|
||||
|
||||
|
||||
def test_unavailable_short_windows_do_not_stop_full_support_search():
|
||||
def register(a, b, seed, *, acquisition):
|
||||
if acquisition and (len(a) < 3000 or len(b) < 2000):
|
||||
return dict(status="rejected", reasons=["synthetic short-window failure"])
|
||||
return exact(a, b, seed, acquisition=acquisition)
|
||||
|
||||
_, report = acquire_closure(source(), register)
|
||||
assert report["status"] == "candidate" and report["complete"]
|
||||
assert not report["attempts"][0]["qualified"]
|
||||
selected = report["attempts"][report["selected_attempt"]]
|
||||
assert (selected["reference_s"], selected["query_s"]) == (40, 30)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["forward", "reverse", "cycle", "exception"])
|
||||
def test_failure_keeps_all_attempts_and_never_materializes_identity(mode):
|
||||
def register(a, b, seed, *, acquisition):
|
||||
if mode == "exception":
|
||||
raise ValueError("unavailable geometry")
|
||||
if (mode == "forward" and acquisition) or (mode == "reverse" and not acquisition):
|
||||
return dict(status="rejected")
|
||||
result = exact(a, b, seed, acquisition=acquisition)
|
||||
if mode == "cycle" and not acquisition:
|
||||
result["T_reference_query"][0][3] += 0.11
|
||||
return result
|
||||
|
||||
with pytest.raises(ClosureUnavailable) as failure:
|
||||
acquire_closure(source(), register)
|
||||
assert failure.value.report["complete"]
|
||||
assert len(failure.value.report["attempts"]) == 12
|
||||
assert failure.value.report["status"] == "rejected"
|
||||
|
||||
|
||||
def test_disagreeing_qualified_windows_reject_instead_of_cherry_picking():
|
||||
def register(a, b, seed, *, acquisition):
|
||||
if acquisition:
|
||||
seed = seed.copy()
|
||||
seed[0, 3] = 2 if len(a) < 1200 else 0
|
||||
return dict(status="candidate", T_reference_query=seed.tolist())
|
||||
|
||||
with pytest.raises(ClosureUnavailable, match="ambiguous") as failure:
|
||||
acquire_closure(source(), register)
|
||||
assert len(failure.value.report["qualified_attempts"]) == 12
|
||||
|
||||
|
||||
def test_short_capture_never_matches_window_to_itself():
|
||||
data = source()
|
||||
data["frames"][:, 0] /= 100
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail("overlapping frames reached registration")
|
||||
|
||||
with pytest.raises(ClosureUnavailable) as failure:
|
||||
acquire_closure(data, forbidden)
|
||||
assert all(
|
||||
r["reason"] == "overlapping-source-windows" for r in failure.value.report["attempts"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"reference_seconds": ()},
|
||||
{"query_seconds": (0,)},
|
||||
{"radius_m": float("nan")},
|
||||
{"query_seconds": (5, 5)},
|
||||
],
|
||||
)
|
||||
def test_policy_validation(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
ClosurePolicy(**kwargs)
|
||||
|
||||
|
||||
def module(name):
|
||||
path = Path(__file__).parents[1] / "experiments" / (name + ".py")
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
result = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(result)
|
||||
return result
|
||||
|
||||
|
||||
def test_acquisition_does_not_change_live_quality_or_decode_split():
|
||||
from k1link.missions.registration import POLICY
|
||||
|
||||
adapter = module("reconstruct_recorded_ring")
|
||||
different = {key for key in POLICY if POLICY[key] != adapter.CLOSURE_REGISTRATION_POLICY[key]}
|
||||
assert different == {"version", "maximum_correction_m", "maximum_correction_deg"}
|
||||
assert POLICY["maximum_correction_m"] == 3
|
||||
legacy = {**adapter.PROFILE, "version": "recorded-ring-experiment/v1"}
|
||||
assert adapter.compatible_cache_profile(legacy)
|
||||
assert not adapter.compatible_cache_profile({**legacy, "holdout_start_s": 5})
|
||||
|
||||
|
||||
def review():
|
||||
before = dict(
|
||||
points=400,
|
||||
query_frames=4,
|
||||
overlap_05m=0.2,
|
||||
inlier_rmse_m=0.15,
|
||||
all_point_distances_m={"median": 1.2},
|
||||
)
|
||||
after = {**before, "overlap_05m": 0.95, "all_point_distances_m": {"median": 0.12}}
|
||||
results = [dict(seam_holdout=before), dict(seam_holdout=after)]
|
||||
validation = [dict(total=12, candidate_count=11), dict(total=12, candidate_count=12)]
|
||||
return results, validation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, "local", "quality", "support", "degradation"])
|
||||
def test_review_gate_records_failure_without_lowering_quality(failure):
|
||||
results, validation = review()
|
||||
if failure == "local":
|
||||
validation[1]["candidate_count"] = 11
|
||||
if failure == "quality":
|
||||
results[1]["seam_holdout"]["inlier_rmse_m"] = 0.26
|
||||
if failure == "support":
|
||||
results[1]["seam_holdout"]["points"] = 20
|
||||
if failure == "degradation":
|
||||
results[0]["seam_holdout"]["overlap_05m"] = 0.99
|
||||
receipt = review_acceptance(results, validation)
|
||||
assert receipt["accepted"] == (failure is None)
|
||||
assert not receipt["independent_accuracy"] and not receipt["vehicle_control"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure", [None, "partial", "missing-check", "rejected", "missing-review"]
|
||||
)
|
||||
def test_packaging_v2_requires_complete_acquisition_and_positive_review(tmp_path, failure):
|
||||
check = module("package_recorded_map_version").check_closure_review
|
||||
search = dict(status="candidate", complete=True, attempts=[{}] * 12, expected_attempts=12)
|
||||
receipt = review_acceptance(*review())
|
||||
if failure == "partial":
|
||||
search["complete"] = False
|
||||
if failure == "missing-check":
|
||||
receipt["checks"].pop("heldout_seam_quality")
|
||||
if failure == "rejected":
|
||||
receipt["checks"]["heldout_seam_quality"] = False
|
||||
(tmp_path / "closure-search.json").write_text(json.dumps(search))
|
||||
(tmp_path / "review.json").write_text(
|
||||
json.dumps({} if failure == "missing-review" else dict(acceptance=receipt))
|
||||
)
|
||||
summary = dict(
|
||||
schema_version="missioncore.recorded-ring-experiment/v2", closure_acquisition="candidate"
|
||||
)
|
||||
if failure:
|
||||
with pytest.raises(ValueError):
|
||||
check(tmp_path, summary)
|
||||
else:
|
||||
assert check(tmp_path, summary) == ["closure-search.json"]
|
||||
assert check(tmp_path, {"schema_version": "missioncore.recorded-ring-experiment/v1"}) == []
|
||||
@@ -0,0 +1,260 @@
|
||||
import json
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.missions.drafts import MissionDrafts
|
||||
from k1link.missions.versioned_sources import VersionedPlanningSources
|
||||
from k1link.reconstruction.map_version import MapVersion, publish_map_version, sha256
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path):
|
||||
p = np.array([[0.0, 0, 0], [10.0, 0, 0], [20.0, 0, 0]])
|
||||
corrected = p + np.array([[0.0, 0, 0], [0, 0, 1], [0, 0, 2]])
|
||||
frames = np.array([[0, 10, 0, 2], [1, 11, 2, 2], [2, 12, 4, 2]], float)
|
||||
points = np.concatenate([xyz + [[0, 0, 0], [0, 0, 10]] for xyz in corrected]).astype("<f4")
|
||||
points.tofile(tmp_path / "points.f32")
|
||||
np.savez(
|
||||
tmp_path / "trajectory.npz",
|
||||
positions=corrected,
|
||||
orientations_xyzw=np.tile([0.0, 0, 0, 1], (3, 1)),
|
||||
receipt_time_s=np.arange(3.0),
|
||||
source_distance_m=np.array([0.0, 10, 20]),
|
||||
frame_source_distance_m=np.array([0.0, 10, 20]),
|
||||
frames=frames,
|
||||
)
|
||||
source = dict(
|
||||
schema_version="missioncore.planning-source/v1",
|
||||
session_id="source-a",
|
||||
generation="a" * 64,
|
||||
label="Original",
|
||||
units="m",
|
||||
frame_id="session/source-a",
|
||||
source_digests={"primary": "b" * 64},
|
||||
decode_errors=0,
|
||||
path_m=20.0,
|
||||
poses=[
|
||||
dict(
|
||||
index=i,
|
||||
message_index=i * 2 + 1,
|
||||
position=xyz.tolist(),
|
||||
elapsed_s=i,
|
||||
distance_m=float(i * 10),
|
||||
)
|
||||
for i, xyz in enumerate(p)
|
||||
],
|
||||
)
|
||||
(tmp_path / "review.json").write_text('{"kind":"synthetic-test-only"}')
|
||||
|
||||
def publish(**overrides):
|
||||
arguments = dict(
|
||||
root=tmp_path / "versions",
|
||||
source=source,
|
||||
points=tmp_path / "points.f32",
|
||||
trajectory=tmp_path / "trajectory.npz",
|
||||
expected_points_sha256=sha256(tmp_path / "points.f32"),
|
||||
expected_trajectory_sha256=sha256(tmp_path / "trajectory.npz"),
|
||||
evidence={"review.json": (tmp_path / "review.json", sha256(tmp_path / "review.json"))},
|
||||
method={"algorithm": "synthetic-fixture/v1"},
|
||||
label="Corrected candidate",
|
||||
)
|
||||
return publish_map_version(**{**arguments, **overrides})
|
||||
|
||||
class Original:
|
||||
calls = []
|
||||
changed = False
|
||||
|
||||
def get(self, session_id):
|
||||
assert session_id == source["session_id"]
|
||||
return source
|
||||
|
||||
def bound(self, session_id, generation):
|
||||
self.calls.append(generation)
|
||||
if (
|
||||
self.changed
|
||||
or session_id != source["session_id"]
|
||||
or generation != source["generation"]
|
||||
):
|
||||
raise ValueError("Original source changed")
|
||||
return source
|
||||
|
||||
verify = bound
|
||||
|
||||
return SimpleNamespace(
|
||||
root=tmp_path,
|
||||
source=source,
|
||||
points=points,
|
||||
corrected=corrected,
|
||||
publish=publish,
|
||||
original=Original(),
|
||||
)
|
||||
|
||||
|
||||
def test_identity_idempotency_and_recomputed_distances(fixture):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
assert f.publish().generation == version.generation
|
||||
assert len(list((f.root / "versions").iterdir())) == 1
|
||||
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
|
||||
assert sources.get("source-a") is f.source
|
||||
assert sources.bound("source-a", "a" * 64) is f.source
|
||||
doc = sources.bound("source-a", version.generation)
|
||||
assert doc["path_m"] == pytest.approx(2 * np.sqrt(101))
|
||||
assert doc["path_m"] != f.source["path_m"]
|
||||
assert doc["frame_id"] != f.source["frame_id"]
|
||||
assert [p["message_index"] for p in doc["poses"]] == [1, 3, 5]
|
||||
np.testing.assert_allclose([p["position"] for p in doc["poses"]], f.corrected)
|
||||
assert doc["reference_version"]["authority"]["production_promotion"] is False
|
||||
assert sources.version.arrays()["source_distance_m"][-1] == 20
|
||||
# Caller mutation cannot affect the sealed artifact or its next projection.
|
||||
doc["poses"][0]["position"][0] = 999
|
||||
assert sources.bound("source-a", version.generation)["poses"][0]["position"][0] == 0
|
||||
|
||||
|
||||
def test_same_session_generations_keep_old_and_new_drafts_distinct(fixture):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
|
||||
drafts = MissionDrafts(f.root / "drafts", sources)
|
||||
|
||||
def save(generation):
|
||||
return drafts.save(
|
||||
SimpleNamespace(
|
||||
session_id="source-a",
|
||||
generation=generation,
|
||||
id=None,
|
||||
revision=0,
|
||||
name="Map test",
|
||||
start_index=0,
|
||||
end_index=2,
|
||||
direction="forward",
|
||||
)
|
||||
)
|
||||
|
||||
old, new = save("a" * 64), save(version.generation)
|
||||
assert old["route"]["length_m"] == 20
|
||||
assert new["route"]["length_m"] == pytest.approx(2 * np.sqrt(101))
|
||||
assert drafts.get(old["id"]) == old
|
||||
assert drafts.check(new["id"], 1)["vehicle_control"] is False
|
||||
assert drafts.check(new["id"], 1)["localization"] == "not_run"
|
||||
|
||||
|
||||
def test_extraction_uses_corrected_frame_ownership_and_separate_display_profile(fixture):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
|
||||
points, provenance = sources.submap("source-a", version.generation, 0, 2)
|
||||
np.testing.assert_allclose(points, f.corrected)
|
||||
assert provenance["raw_points"] == 6 and provenance["retained_points"] == 3
|
||||
assert [p["sequence"] for p in provenance["frames"]] == [10, 11, 12]
|
||||
display, meta = sources.submap("source-a", version.generation, 0, 2, presentation=True)
|
||||
np.testing.assert_allclose(display, f.points)
|
||||
assert meta["extraction"]["radius_m"] == 80
|
||||
assert meta["extraction"]["height_relative_m"] is None
|
||||
whole, info = sources.reference_map("source-a", version.generation, 0, 2)
|
||||
np.testing.assert_allclose(whole, f.corrected)
|
||||
assert info["generation"] == version.generation
|
||||
assert not list((f.root / "scratch").iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["manifest.json", "points.f32", "trajectory.npz", "review.json"])
|
||||
def test_tampered_bundle_cannot_be_used(fixture, target):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
path = version.directory / target
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(b"X" + original[1:])
|
||||
with pytest.raises(ValueError):
|
||||
MapVersion(version.directory, version.generation).verify()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["original", "derivative", "cancel", "exception"])
|
||||
def test_preparation_is_private_and_postchecked(fixture, mode):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
|
||||
cancel = threading.Event()
|
||||
with pytest.raises((ValueError, InterruptedError)), sources.prepared_submaps(
|
||||
"source-a", version.generation, cancel_event=cancel
|
||||
) as extract:
|
||||
if mode == "original":
|
||||
f.original.changed = True
|
||||
elif mode == "derivative":
|
||||
(version.directory / "points.f32").write_bytes(b"X" * 72)
|
||||
# Still reads the private copy; final verification rejects publication.
|
||||
np.testing.assert_allclose(extract(0, 2)[0], f.corrected)
|
||||
elif mode == "cancel":
|
||||
cancel.set()
|
||||
extract(0, 2)
|
||||
else:
|
||||
raise ValueError("Synthetic preparation failure")
|
||||
assert not list((f.root / "scratch").iterdir())
|
||||
|
||||
|
||||
def test_wrong_parent_and_no_silent_fallback(fixture):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
sources = VersionedPlanningSources(f.original, version, f.root / "scratch")
|
||||
with pytest.raises(ValueError):
|
||||
sources.bound("source-b", version.generation)
|
||||
with pytest.raises(ValueError):
|
||||
sources.bound("source-a", "c" * 64)
|
||||
with pytest.raises(ValueError):
|
||||
version.planning_source({**f.source, "generation": "d" * 64})
|
||||
with pytest.raises(ValueError):
|
||||
version.planning_source({**f.source, "source_digests": {"primary": "d" * 64}})
|
||||
|
||||
|
||||
def test_publication_rejects_bad_hash_unsafe_names_nonfinite_points_and_links(fixture):
|
||||
f = fixture
|
||||
with pytest.raises(ValueError):
|
||||
f.publish(expected_points_sha256="e" * 64)
|
||||
with pytest.raises(ValueError):
|
||||
f.publish(
|
||||
evidence={"../escape.json": (f.root / "review.json", sha256(f.root / "review.json"))}
|
||||
)
|
||||
path = f.root / "link.f32"
|
||||
path.symlink_to(f.root / "points.f32")
|
||||
with pytest.raises(ValueError):
|
||||
f.publish(points=path)
|
||||
broken = f.points.copy()
|
||||
broken[0, 0] = np.nan
|
||||
broken.tofile(f.root / "points.f32")
|
||||
with pytest.raises(ValueError):
|
||||
f.publish()
|
||||
assert not list((f.root / "versions").iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["offset", "size", "clock", "source-distance", "quaternion"])
|
||||
def test_publication_rejects_inconsistent_frame_pose_bindings(fixture, mode):
|
||||
f = fixture
|
||||
path = f.root / "trajectory.npz"
|
||||
with np.load(path) as data:
|
||||
values = {key: data[key].copy() for key in data.files}
|
||||
if mode == "offset":
|
||||
values["frames"][1, 2] = 1
|
||||
elif mode == "size":
|
||||
values["frames"][-1, 3] = 1
|
||||
elif mode == "clock":
|
||||
values["receipt_time_s"][1] = 4
|
||||
elif mode == "source-distance":
|
||||
values["frame_source_distance_m"][1] = 1
|
||||
else:
|
||||
values["orientations_xyzw"][0] = 0
|
||||
np.savez(path, **values)
|
||||
with pytest.raises(ValueError):
|
||||
f.publish()
|
||||
assert not list((f.root / "versions").iterdir())
|
||||
|
||||
|
||||
def test_different_review_creates_new_version_without_overwrite(fixture):
|
||||
f = fixture
|
||||
first = f.publish()
|
||||
(f.root / "review.json").write_text(json.dumps({"kind": "another-reviewed-candidate"}))
|
||||
second = f.publish()
|
||||
assert first.generation != second.generation
|
||||
first.verify()
|
||||
second.verify()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Receipt artifact ownership for the bounded K1 experiment adapter, no HTTP."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.reconstruction.map_version import sha256
|
||||
|
||||
|
||||
def adapter():
|
||||
pytest.importorskip("scipy") # The packaging command uses the optional correction extra.
|
||||
path = Path(__file__).parents[1] / "experiments/package_recorded_map_version.py"
|
||||
spec = importlib.util.spec_from_file_location("package_recorded_map_version", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.RecordedParent
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changed", [None, "raw", "metadata", "origin", "clock"])
|
||||
def test_native_artifact_ids_match_plugin_contract(tmp_path, monkeypatch, changed):
|
||||
raw = tmp_path / "mqtt.raw.k1mqtt"
|
||||
metadata = tmp_path / "mqtt.metadata.jsonl"
|
||||
origin = tmp_path / "mqtt.timeline.origin.json"
|
||||
raw.write_bytes(b"synthetic raw")
|
||||
metadata.write_bytes(b"synthetic per-message receipt index")
|
||||
origin.write_bytes(b"synthetic clock origin")
|
||||
current = tmp_path / "mqtt.timeline.json"
|
||||
current.write_bytes(b"synthetic frozen clock")
|
||||
clock_digest = sha256(current)
|
||||
frozen = tmp_path / f"mqtt.timeline.session-{clock_digest}.json"
|
||||
frozen.write_bytes(current.read_bytes())
|
||||
current.write_bytes(b"later mutable clock is not the source session snapshot")
|
||||
source = dict(
|
||||
generation="a" * 64,
|
||||
source_digests={
|
||||
"raw-transport-primary": sha256(raw),
|
||||
"raw-transport-index": sha256(metadata),
|
||||
"raw-transport-clock-origin": sha256(origin),
|
||||
"raw-transport-clock": clock_digest,
|
||||
},
|
||||
)
|
||||
parent = adapter()(raw, "source-a", "a" * 64)
|
||||
monkeypatch.setattr(parent, "get", lambda _: source)
|
||||
if changed:
|
||||
{"raw": raw, "metadata": metadata, "origin": origin, "clock": frozen}[changed].write_bytes(
|
||||
b"changed"
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
parent.verify("source-a", "a" * 64)
|
||||
else:
|
||||
assert parent.verify("source-a", "a" * 64) == source
|
||||
with pytest.raises(ValueError):
|
||||
parent.verify("source-a", "b" * 64)
|
||||
@@ -66,6 +66,25 @@ def test_draft_persists_full_route_and_optimistic_revision(tmp_path):
|
||||
assert service.get(first['id']) == next
|
||||
|
||||
|
||||
def test_whole_recording_is_resolved_by_server_and_old_report_stays_frozen(tmp_path):
|
||||
service = MissionDrafts(tmp_path, Sources())
|
||||
old = service.save(request())
|
||||
full = service.save(request(id=old['id'], revision=1, whole_recording=True,
|
||||
start_index=1, end_index=2))
|
||||
assert (full['route']['start_index'], full['route']['end_index']) == (0, 3)
|
||||
assert full['route']['length_m'] == 15
|
||||
assert old['route']['length_m'] == 10
|
||||
app = FastAPI()
|
||||
app.include_router(build_mission_planner_router(service))
|
||||
with TestClient(app) as client:
|
||||
body = {'name': 'Full', 'session_id': 'session-a', 'generation': 'a'*64,
|
||||
'whole_recording': True, 'direction': 'reverse'}
|
||||
response = client.post('/api/v1/mission-planner/drafts', json=body)
|
||||
assert response.status_code == 200
|
||||
assert [p['source_index'] for p in response.json()['route']['points']] == [3, 2, 1, 0]
|
||||
assert client.post('/api/v1/mission-planner/drafts', json={**body, 'whole_recording': False}).status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.parametrize('start,end', [(2, 2), (3, 1), (-1, 2), (0, 4)])
|
||||
def test_route_rejects_out_of_bounds(start, end):
|
||||
with pytest.raises(ValueError): route_from_source(source_doc(), start, end, 'forward')
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import rerun as rr
|
||||
from rerun.experimental import RrdReader
|
||||
|
||||
from k1link.sessions.overview_comparison import load_comparison, publish_comparison
|
||||
from k1link.sessions.overview_spatial import render_spatial_update, spatial_metadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pair(tmp_path):
|
||||
root = tmp_path / "previews"
|
||||
inputs = dict(
|
||||
session_id="recording",
|
||||
overview_generation="a" * 64,
|
||||
source_digests={"raw": "b" * 64},
|
||||
map_generation="c" * 64,
|
||||
source_points=30,
|
||||
original_path_m=12.0,
|
||||
corrected_path_m=11.9,
|
||||
original=[[0, 0, 0], [1, 2, 3], [4, 5, 8]],
|
||||
corrected=[[0, 0, 0], [1, 2, 2], [4, 5, 7]],
|
||||
original_route=[[0, 0, 0], [4, 5, 8]],
|
||||
corrected_route=[[0, 0, 0], [4, 5, 7]],
|
||||
)
|
||||
generation = publish_comparison(root, **inputs)
|
||||
return root, generation, inputs
|
||||
|
||||
|
||||
def load(pair, **changes):
|
||||
root, generation, inputs = pair
|
||||
values = {k: inputs[k] for k in ("overview_generation", "session_id", "source_digests")}
|
||||
return load_comparison(root, **{**values, **changes}, generation=generation)
|
||||
|
||||
|
||||
def test_publication_is_source_bound_idempotent_and_not_a_new_session(pair):
|
||||
root, generation, inputs = pair
|
||||
assert publish_comparison(root, **inputs) == generation
|
||||
preview = load(pair)
|
||||
np.testing.assert_array_equal(preview.original, inputs["original"])
|
||||
assert preview.document["view_only"] is True
|
||||
assert sorted(p.name for p in root.iterdir()) == [generation, "selected"]
|
||||
assert (
|
||||
load_comparison(
|
||||
root, inputs["overview_generation"], "recording", inputs["source_digests"]
|
||||
).generation
|
||||
== generation
|
||||
)
|
||||
assert load_comparison(root, "d" * 64, "recording", inputs["source_digests"]) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes",
|
||||
[
|
||||
dict(session_id="other"),
|
||||
dict(overview_generation="d" * 64),
|
||||
dict(source_digests={"raw": "e" * 64}),
|
||||
],
|
||||
)
|
||||
def test_does_not_cross_source_boundaries(pair, changes):
|
||||
with pytest.raises(ValueError, match="another recording"):
|
||||
load(pair, **changes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["manifest.json", "geometry.npz"])
|
||||
def test_corrupt_artifacts_fail_closed(pair, filename):
|
||||
root, generation, _ = pair
|
||||
path = root / generation / filename
|
||||
path.write_bytes(path.read_bytes() + b"X")
|
||||
with pytest.raises(ValueError, match="changed"):
|
||||
load(pair)
|
||||
|
||||
|
||||
def test_geometry_rejects_non_finite_or_mismatched_pairs(pair):
|
||||
root, _, inputs = pair
|
||||
with pytest.raises(ValueError, match="geometry"):
|
||||
publish_comparison(root, **{**inputs, "corrected": [[float("nan"), 0, 0]]})
|
||||
|
||||
|
||||
def test_path_traversal_and_symlinks_are_rejected(pair):
|
||||
root, generation, inputs = pair
|
||||
with pytest.raises(ValueError, match="identity"):
|
||||
load_comparison(
|
||||
root, inputs["overview_generation"], "recording", inputs["source_digests"], "../source"
|
||||
)
|
||||
path = root / generation / "geometry.npz"
|
||||
path.rename(path.with_suffix(".original"))
|
||||
path.symlink_to(path.with_suffix(".original"))
|
||||
with pytest.raises(ValueError, match="artifact"):
|
||||
load(pair)
|
||||
|
||||
|
||||
def test_switch_replaces_cloud_route_endpoints_without_camera_blueprint(pair, tmp_path):
|
||||
preview = load(pair)
|
||||
original = tmp_path / "scene.rrd"
|
||||
recording = rr.RecordingStream("overview-test")
|
||||
recording.save(original)
|
||||
recording.log("world/cloud", rr.Points3D(preview.original, colors=[30, 40, 50]), static=True)
|
||||
recording.flush()
|
||||
recording.disconnect()
|
||||
digest = hashlib.sha256(original.read_bytes()).hexdigest()
|
||||
metadata = spatial_metadata(original, preview)
|
||||
assert metadata["comparison"]["height_max_m"] == 80
|
||||
assert metadata["comparison"]["source_points"] == 30
|
||||
colors = []
|
||||
for representation in ("original", "corrected", "original"):
|
||||
data, count, eye = render_spatial_update(
|
||||
original, None, None, comparison=preview, representation=representation
|
||||
)
|
||||
assert count == 3 and eye is None
|
||||
path = tmp_path / f"{representation}.rrd"
|
||||
path.write_bytes(data)
|
||||
reader = RrdReader(path)
|
||||
assert len(reader.recordings()) == 1
|
||||
assert (
|
||||
reader.recordings()[0].recording_id == RrdReader(original).recordings()[0].recording_id
|
||||
)
|
||||
chunks = {c.entity_path: c.to_record_batch() for c in reader.stream()}
|
||||
assert set(chunks) == {"/world/cloud", "/world/route", "/world/endpoints"}
|
||||
points = (
|
||||
chunks["/world/cloud"]
|
||||
.column("Points3D:positions")[0]
|
||||
.values.values.to_numpy()
|
||||
.reshape(-1, 3)
|
||||
)
|
||||
expected = getattr(preview, representation)
|
||||
np.testing.assert_array_equal(points, expected)
|
||||
endpoints = (
|
||||
chunks["/world/endpoints"]
|
||||
.column("Points3D:positions")[0]
|
||||
.values.values.to_numpy()
|
||||
.reshape(-1, 3)
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
endpoints, getattr(preview, representation + "_route")[[0, -1]]
|
||||
)
|
||||
colors.append(chunks["/world/cloud"].column("Points3D:colors")[0].values.to_numpy())
|
||||
np.testing.assert_array_equal(colors[0], colors[1])
|
||||
assert hashlib.sha256(original.read_bytes()).hexdigest() == digest
|
||||
assert (
|
||||
render_spatial_update(original, 2.5, None, comparison=preview, representation="corrected")[
|
||||
1
|
||||
]
|
||||
== 2
|
||||
)
|
||||
assert (
|
||||
render_spatial_update(original, 2.5, None, comparison=preview, representation="original")[1]
|
||||
== 1
|
||||
)
|
||||
eyes = [
|
||||
render_spatial_update(original, None, "top", comparison=preview, representation=x)[2]
|
||||
for x in ("original", "corrected")
|
||||
]
|
||||
assert eyes[0] == eyes[1]
|
||||
with pytest.raises(ValueError, match="requires a pinned"):
|
||||
render_spatial_update(original, None, None, representation="corrected")
|
||||
|
||||
|
||||
def test_pinned_comparison_does_not_follow_new_selection(pair):
|
||||
root, generation, inputs = pair
|
||||
newer = publish_comparison(root, **{**inputs, "corrected": [[0, 0, 0], [1, 2, 1], [4, 5, 6]]})
|
||||
assert generation != newer
|
||||
assert load(pair).generation == generation
|
||||
assert (
|
||||
json.loads((root / "selected" / (inputs["overview_generation"] + ".json")).read_text())[
|
||||
"generation"
|
||||
]
|
||||
== newer
|
||||
)
|
||||
|
||||
|
||||
def test_http_requires_pinned_version_and_never_falls_back_to_original(pair, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.web.session_overview_api import build_session_overview_router
|
||||
|
||||
path = tmp_path / "scene.rrd"
|
||||
recording = rr.RecordingStream("http-overview-test")
|
||||
recording.save(path)
|
||||
recording.log("world/cloud", rr.Points3D([[0, 0, 0]], colors=[10, 20, 30]), static=True)
|
||||
recording.flush()
|
||||
recording.disconnect()
|
||||
root, generation, inputs = pair
|
||||
|
||||
def scene(session, overview):
|
||||
if session != "recording" or overview != inputs["overview_generation"]:
|
||||
raise ValueError("source changed")
|
||||
return path
|
||||
|
||||
def comparison(session, overview, pinned=None):
|
||||
scene(session, overview)
|
||||
return load_comparison(root, overview, session, inputs["source_digests"], pinned)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
build_session_overview_router(SimpleNamespace(scene=scene, comparison=comparison,
|
||||
default_representation=lambda *_: "original"))
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
url = "/api/v1/observation-sessions/recording/overview/spatial"
|
||||
response = client.get(url, params={"generation": inputs["overview_generation"]})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["comparison"]["generation"] == generation
|
||||
request = dict(generation=inputs["overview_generation"], representation="corrected")
|
||||
assert client.post(url, json=request).status_code == 409
|
||||
assert (
|
||||
client.post(url, json={**request, "comparison_generation": "f" * 64}).status_code == 409
|
||||
)
|
||||
assert (
|
||||
client.post(url, json={**request, "comparison_generation": "../source"}).status_code
|
||||
== 422
|
||||
)
|
||||
response = client.post(url, json={**request, "comparison_generation": generation})
|
||||
assert response.status_code == 200
|
||||
assert "X-Overview-Eye" not in response.headers
|
||||
assert response.headers["X-Overview-Visible-Points"] == "3"
|
||||
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
import threading
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from test_session_store import make_legacy_session
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
|
||||
from k1link.missions.capture_catalog import reconcile_planning_captures
|
||||
from k1link.missions.live_tests import PlanningLiveTests
|
||||
from k1link.sessions import SessionStore
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
|
||||
def test_standalone_projection_is_durable_paged_and_preserves_raw_evidence(tmp_path):
|
||||
repo, data = tmp_path / "repo", tmp_path / "data"
|
||||
archive = xgrids_k1_archive_source(repo / "sessions")
|
||||
store = SessionStore(repo, data_dir=data)
|
||||
run = str(uuid4())
|
||||
# Acquisition can claim its identity before the finished capture is indexed.
|
||||
store.record_planning_capture("pass-b_viewer_live", run)
|
||||
for name in [
|
||||
"survey-a_viewer_live",
|
||||
"pass-b_viewer_live",
|
||||
"survey-c_viewer_live",
|
||||
"pass-d_viewer_live",
|
||||
]:
|
||||
make_legacy_session(repo / "sessions", name)
|
||||
store.reconcile_archive(archive)
|
||||
before = store.get_session_with_catalog_snapshot("pass-d_viewer_live")
|
||||
store.record_planning_capture("pass-d_viewer_live", str(uuid4()))
|
||||
store.record_planning_capture("pass-b_viewer_live", run)
|
||||
assert store.get_session_with_catalog_snapshot("pass-d_viewer_live") == before
|
||||
assert len(store.list_recent(scope="source").items) == 4
|
||||
assert len(store.list_recent(scope="all").items) == 4
|
||||
assert not store.list_recent(scope="laboratory").items
|
||||
first = store.list_recent(scope="standalone", limit=1)
|
||||
assert [s.session_id for s in first.items] == ["survey-c_viewer_live"]
|
||||
second = store.list_recent(scope="standalone", limit=1, cursor=first.next_cursor)
|
||||
assert [s.session_id for s in second.items] == ["survey-a_viewer_live"]
|
||||
assert second.next_cursor is None
|
||||
reopened = SessionStore(repo, data_dir=data)
|
||||
reopened.reconcile_archive(archive)
|
||||
assert len(reopened.list_recent(scope="standalone").items) == 2
|
||||
assert reopened.prepare_replay("pass-b_viewer_live").session_id == "pass-b_viewer_live"
|
||||
app = FastAPI()
|
||||
app.include_router(build_session_router(reopened))
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/api/v1/observation-sessions?scope=standalone&limit=1&pagination=cursor-v1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["items"][0]["id"] == "survey-c_viewer_live"
|
||||
assert client.get("/api/v1/observation-sessions/pass-b_viewer_live").status_code == 200
|
||||
|
||||
|
||||
def test_historical_migration_uses_live_bindings_not_names_or_recorded_comparisons(tmp_path):
|
||||
run = str(uuid4())
|
||||
directory = tmp_path / run
|
||||
directory.mkdir()
|
||||
doc = dict(
|
||||
schema_version="missioncore.planning-live-test/v1",
|
||||
id=run,
|
||||
profile="planning",
|
||||
state="cancelled",
|
||||
query_session_id="any-name",
|
||||
baseline_session_id="previous",
|
||||
draft={"zone": {"session_id": "survey"}},
|
||||
)
|
||||
report = directory / "report.json"
|
||||
report.write_text(json.dumps(doc))
|
||||
recorded = tmp_path / str(uuid4())
|
||||
recorded.mkdir()
|
||||
(recorded / "report.json").write_text(
|
||||
json.dumps({**doc, "schema_version": "missioncore.registration-run/v1"})
|
||||
)
|
||||
calls = []
|
||||
reconcile_planning_captures(tmp_path, lambda *args: calls.append(args))
|
||||
assert calls == [("any-name", run)]
|
||||
assert json.loads(report.read_text()) == doc
|
||||
report.write_text(json.dumps({**doc, "query_session_id": "survey"}))
|
||||
with pytest.raises(ValueError, match="reference"):
|
||||
reconcile_planning_captures(tmp_path, lambda *args: calls.append(args))
|
||||
|
||||
|
||||
def test_live_binding_records_origin_before_publishing_run(tmp_path):
|
||||
# Exercise the real update boundary without starting any device/worker.
|
||||
service = object.__new__(PlanningLiveTests)
|
||||
service.lock = threading.RLock()
|
||||
service.run = {"id": str(uuid4()), "query_session_id": None}
|
||||
service.revision = 0
|
||||
events = []
|
||||
service.capture_recorder = lambda *args: events.append(("capture", args))
|
||||
service.persist = lambda: events.append(("persist", service.run.copy()))
|
||||
service.update(query_session_id="new-pass", query_generation=2, state="running")
|
||||
assert [e[0] for e in events] == ["capture", "persist"]
|
||||
assert events[0][1] == ("new-pass", service.run["id"])
|
||||
@@ -186,11 +186,13 @@ def test_receipt_gap_is_not_an_instantaneous_jump_and_clears_fit_window():
|
||||
b.ingest(event('pose',t=11.1,p=(21,0,0)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loss", ["stale", "reference-coverage", "fit-rejected", "worker-error", "pose-jump", "operator-stop", "operator-stop-pending"])
|
||||
@pytest.mark.parametrize("loss", ["stale", "reference-coverage", "fit-rejected", "worker-error", "pose-jump", "operator-stop", "operator-stop-pending", "multi-lap"])
|
||||
def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh_windows(tmp_path,monkeypatch,loss):
|
||||
import k1link.missions.live_tests as module
|
||||
from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY
|
||||
service,source,lock,_=fixture_service(tmp_path,monkeypatch)
|
||||
service,source,lock,draft=fixture_service(tmp_path,monkeypatch)
|
||||
if loss == "multi-lap":
|
||||
draft['route'].update(length_m=4, end_index=4, points=draft['route']['points'][:5])
|
||||
clock=[100.]
|
||||
monkeypatch.setattr(module,'time',SimpleNamespace(
|
||||
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
|
||||
@@ -248,7 +250,12 @@ def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh
|
||||
until(lambda:service.get()['planning_phase']=='searching')
|
||||
# Capture continues while the worker searches; an actual post-ready
|
||||
# receipt gap must invalidate the prior, not be hidden by this fixture.
|
||||
for i in range(40):frame(170+i*.5)
|
||||
for i in range(40):
|
||||
# Receipts cannot be ahead of the live clock during a continuity
|
||||
# check. Drain each fixture frame at its actual delivery time.
|
||||
clock[0] = 170 + i * .5 + .01
|
||||
frame(170 + i * .5)
|
||||
until(lambda:source.queue.empty())
|
||||
until(lambda:source.queue.empty())
|
||||
clock[0]=190
|
||||
entry_release.set()
|
||||
@@ -271,6 +278,31 @@ def test_live_stationary_bootstrap_keeps_calibration_separate_and_requires_fresh
|
||||
assert service._scene_result['matched_query_indices']==([0] if j==2 else [])
|
||||
assert calls==['entry','fresh','fresh','fresh']
|
||||
assert service.get()['planning_phase']=='tracking'
|
||||
if loss == "multi-lap":
|
||||
# Exercise the real bootstrap/gate as well as the loop. The fit is
|
||||
# synthetic, but fresh windows must continue beyond the old cap.
|
||||
service.update(maximum_distance_m=4)
|
||||
begin = clock[0] + .5
|
||||
for i in range(1, 25):
|
||||
leg, offset = divmod(i - 1, 8)
|
||||
position = (offset + 1) * .5 if leg % 2 == 0 else 4 - (offset + 1) * .5
|
||||
clock[0] = begin + i * .5 + .002
|
||||
frame(begin + i * .5, p=(position, 0, 0))
|
||||
until(source.queue.empty)
|
||||
until(lambda: service.get()['distance_m'] == 12)
|
||||
begin = clock[0] + .5
|
||||
for i in range(12):
|
||||
clock[0] = begin + i * .5 + .002
|
||||
frame(begin + i * .5, p=(4, 0, 0))
|
||||
until(source.queue.empty)
|
||||
until(lambda: calls.count('fresh') >= 6)
|
||||
assert service.get()['state'] == 'running'
|
||||
assert service.get()['planning_phase'] == 'tracking'
|
||||
assert service.accepted_sample is not None
|
||||
assert service.get().get('recovery_attempt', 0) == 0
|
||||
assert service.get().get('termination_reason') is None
|
||||
assert source.state['active'] and lock.locked()
|
||||
return
|
||||
if loss in {"operator-stop", "operator-stop-pending"}:
|
||||
if loss == "operator-stop-pending":
|
||||
fail_next[0] = loss
|
||||
@@ -360,7 +392,7 @@ def test_operator_can_retry_a_failed_route_identification_without_stopping_captu
|
||||
monkeypatch.setattr(module,'time',SimpleNamespace(
|
||||
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
|
||||
searches=[]
|
||||
def rejected_entry(*_args):
|
||||
def rejected_entry(*_args, **_kwargs):
|
||||
searches.append('entry')
|
||||
return dict(status='rejected',T_reference_query=np.eye(4).tolist(),matched_query_indices=[],
|
||||
overlap=.6,inlier_rmse_m=.28,reasons=['Большое расстояние между поверхностями.'],
|
||||
@@ -439,3 +471,49 @@ def test_stationary_cancel_while_searching_does_not_publish_late_prior(tmp_path,
|
||||
release.set();service.close()
|
||||
assert service.accepted_sample is None and service.get()['result'] is None
|
||||
assert not lock.locked() and source.owner is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cause", ["operator-stop", "motion", "source-ended"])
|
||||
def test_long_search_receives_cancellation_without_waiting_for_total_search(tmp_path,monkeypatch,cause):
|
||||
import k1link.missions.live_tests as module
|
||||
from k1link.missions.route_relocalization_worker import incomplete_result
|
||||
service,source,lock,_=fixture_service(tmp_path,monkeypatch)
|
||||
clock=[100.]
|
||||
entered, cancelled = threading.Event(), threading.Event()
|
||||
monkeypatch.setattr(module,'time',SimpleNamespace(
|
||||
monotonic=lambda:clock[0],monotonic_ns=lambda:int(clock[0]*1e9)))
|
||||
def search(*args, cancel_event, **kwargs):
|
||||
entered.set()
|
||||
assert cancel_event.wait(3), 'Planner did not cancel its own search child'
|
||||
cancelled.set()
|
||||
return incomplete_result('worker-cancelled')
|
||||
monkeypatch.setattr(module,'run_route_relocalization',search)
|
||||
run=service.start('draft',1)
|
||||
points=np.random.default_rng(11).uniform([-1,-3,-1],[8,3,3],(1500,3))
|
||||
try:
|
||||
until(lambda:service.get()['state']=='waiting')
|
||||
source.state.update(active=True,session_id='B',session_generation=2)
|
||||
for i in range(20):
|
||||
source.queue.put(event('pose',t=100+i*.5,sequence=i*2+1))
|
||||
source.queue.put(event('points',t=100+i*.5+.001,sequence=i*2+2,points=points))
|
||||
until(source.queue.empty)
|
||||
clock[0]=110.01
|
||||
until(entered.is_set)
|
||||
if cause=='operator-stop':
|
||||
service.stop(run['id'])
|
||||
elif cause=='source-ended':
|
||||
source.state['active']=False
|
||||
else:
|
||||
source.queue.put(event('pose',t=110.005,sequence=41,p=(.2,0,0)))
|
||||
until(lambda:service.get()['planning_phase']=='lost')
|
||||
until(cancelled.is_set)
|
||||
assert service.accepted_sample is None
|
||||
if cause=='motion':
|
||||
assert service.get()['state']=='running' and source.state['active']
|
||||
service.request_reinitialization(run['id'])
|
||||
until(lambda:service.get()['planning_phase']=='waiting-cloud')
|
||||
assert service.get()['initialization_attempt']==2
|
||||
finally:
|
||||
source.state['active']=False
|
||||
service.close()
|
||||
assert not lock.locked() and source.owner is None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Selected route length drives live admission, reference coverage and termination."""
|
||||
"""Reference coverage and admission never impose a live travel budget."""
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
@@ -14,11 +14,12 @@ from k1link.missions.reference_window import reference_window
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [3, 30, 50, 100, 200, 300, 10_000])
|
||||
def test_limits_come_from_selected_length(length):
|
||||
def test_reference_length_does_not_limit_live_distance_or_time(length):
|
||||
limits = live_route_limits(length)
|
||||
assert limits["maximum_distance_m"] == length
|
||||
assert limits["maximum_distance_m"] is None
|
||||
assert limits["maximum_seconds"] is None
|
||||
assert limits["route_policy"]["maximum_m"] is None
|
||||
assert limits["route_policy"]["version"] == "selected-live-route/v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [0, 2.9, float("nan"), float("inf")])
|
||||
@@ -67,8 +68,11 @@ def test_long_path_does_not_freeze_tail_or_multiply_distance():
|
||||
np.testing.assert_allclose(buffer.path[-1], [2099 * 0.06, 0, 0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("length", [20, 50, 100, 200, 300])
|
||||
def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeypatch, length):
|
||||
@pytest.mark.parametrize("length", [20, 100, 300])
|
||||
@pytest.mark.parametrize("ending", ["operator", "session-end", "spatial-stop-requested"])
|
||||
def test_live_travel_beyond_multiple_reference_lengths_requires_explicit_end(
|
||||
tmp_path, monkeypatch, length, ending
|
||||
):
|
||||
import k1link.missions.stationary_live as module
|
||||
|
||||
# Isolate termination from the separately tested geometric state machine.
|
||||
@@ -111,7 +115,9 @@ def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeyp
|
||||
run = service.start("draft", 1)
|
||||
try:
|
||||
until(lambda: service.get()["state"] == "waiting")
|
||||
assert run["maximum_distance_m"] == length
|
||||
assert run["maximum_distance_m"] is None
|
||||
# An old field cannot silently resurrect distance-based termination.
|
||||
service.update(maximum_distance_m=length)
|
||||
clock[0] = 4000.0 # Neither waiting nor an active run has an implicit time cap.
|
||||
source.state.update(active=True, session_id="B", session_generation=2)
|
||||
source.queue.put(event("pose", t=1))
|
||||
@@ -120,15 +126,36 @@ def test_actual_live_loop_stops_at_selected_distance_not_forty(tmp_path, monkeyp
|
||||
)
|
||||
until(lambda: service.get().get("planning_phase") == "collecting")
|
||||
clock[0] = 8000.0
|
||||
# Queue up to just before the chosen boundary, then prove it remains live.
|
||||
for i in range(1, length):
|
||||
source.queue.put(event("pose", t=1 + i, p=(i, 0, 0), sequence=i + 2))
|
||||
until(source.queue.empty)
|
||||
assert service.get()["state"] == "running"
|
||||
source.queue.put(event("pose", t=1 + length, p=(length, 0, 0), sequence=length + 2))
|
||||
until(lambda: service.get()["state"] == "completed")
|
||||
assert service.get()["termination_reason"] == "distance-limit"
|
||||
assert service.get()["distance_m"] == length
|
||||
# Three legs on the same geometry: travel exceeds the route while the
|
||||
# current location stays on it. Synchronize via a consumed cloud, not
|
||||
# queue.empty(), which can race the consumer's final update.
|
||||
sequence = 3
|
||||
for leg in range(3):
|
||||
for offset in range(1, length + 1):
|
||||
traveled = leg * length + offset
|
||||
position = offset if leg % 2 == 0 else length - offset
|
||||
source.queue.put(
|
||||
event("pose", t=1 + traveled, p=(position, 0, 0), sequence=sequence)
|
||||
)
|
||||
sequence += 1
|
||||
clock[0] += 1
|
||||
source.queue.put(event("points", t=1.001 + traveled, sequence=sequence,
|
||||
points=np.array([[position, 0, 1.]])))
|
||||
sequence += 1
|
||||
until(lambda expected=traveled: service.get()["distance_m"] == expected)
|
||||
assert service.get()["state"] == "running"
|
||||
assert service.get().get("termination_reason") is None
|
||||
assert lock.locked() and source.owner is not None
|
||||
if ending == "operator":
|
||||
service.stop(run["id"])
|
||||
expected_state, expected_reason = "cancelled", "cancelled"
|
||||
else:
|
||||
source.queue.put(event(ending, t=2 + traveled, sequence=sequence))
|
||||
expected_state = "completed"
|
||||
expected_reason = "input-ended" if ending == "session-end" else ending
|
||||
until(lambda: service.get()["state"] == expected_state)
|
||||
assert service.get()["termination_reason"] == expected_reason
|
||||
assert service.get()["distance_m"] == 3 * length
|
||||
assert service.accepted_sample is None
|
||||
assert source.state["active"] is True # Calculation never stops capture.
|
||||
finally:
|
||||
|
||||
@@ -7,6 +7,8 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rerun.experimental import RrdReader
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
||||
RecordedPointColorOverlayStore,
|
||||
@@ -94,7 +96,7 @@ def _command(tmp_path: Path) -> ReplayCommand:
|
||||
)
|
||||
|
||||
|
||||
def test_recorded_color_overlay_logs_only_colors_at_operator_frame_cadence(
|
||||
def test_recorded_color_overlay_logs_only_colors_for_every_captured_frame(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = RecordedPointColorOverlayStore()
|
||||
@@ -129,6 +131,11 @@ def test_recorded_color_overlay_logs_only_colors_at_operator_frame_cadence(
|
||||
assert printed.count("Points3D:colors") == 2
|
||||
assert "Points3D:positions" not in printed
|
||||
assert "session_time" in printed
|
||||
rows = 0
|
||||
for chunk in RrdReader(output).stream():
|
||||
if chunk.entity_path == "/world/points":
|
||||
rows += chunk.to_record_batch().num_rows
|
||||
assert rows == 6
|
||||
|
||||
|
||||
def test_recorded_color_overlay_reuses_index_and_identical_payload(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import struct
|
||||
import pytest
|
||||
from rerun.experimental import RrdReader
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_display import retained_indices, render_point_display
|
||||
from test_recorded_point_colors import _command
|
||||
|
||||
|
||||
def test_sampling_endpoints_exact_fraction_and_nested_stable_rows():
|
||||
np.testing.assert_array_equal(retained_indices(1000, 0, 7), np.arange(1000))
|
||||
assert len(retained_indices(1000, 100, 7)) == 0
|
||||
for percent in (5, 49, 49.5, 50, 99.9):
|
||||
actual = retained_indices(1000, percent, 7)
|
||||
assert len(actual) == round(1000 * (100 - percent) / 100)
|
||||
np.testing.assert_array_equal(actual, retained_indices(1000, percent, 7))
|
||||
assert len(set(actual)) == len(actual)
|
||||
assert set(retained_indices(1000, 75, 7)) < set(retained_indices(1000, 50, 7))
|
||||
assert not np.array_equal(retained_indices(1000, 50, 7), retained_indices(1000, 50, 8))
|
||||
for percent in (-1, 101, float('nan'), float('inf')):
|
||||
with pytest.raises(ValueError): retained_indices(100, percent, 0)
|
||||
|
||||
|
||||
def test_stream_is_display_only_and_keeps_full_timeline(tmp_path: Path):
|
||||
command = _command(tmp_path)
|
||||
original = command.primary_artifact.path.read_bytes()
|
||||
for bank in ('0' * 32, '1' * 32):
|
||||
payload = b''.join(render_point_display(command, application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001", color_mode="height", palette="viridis", custom_color="#112233",
|
||||
point_decimation_percent=5, display_bank=bank))
|
||||
assert payload.startswith(b'NPD1')
|
||||
length = struct.unpack('<I', payload[4:8])[0]
|
||||
assert payload[8:12] == b'RRF2'
|
||||
assert payload[8 + length:] == b'\0' * 4
|
||||
path = tmp_path / f"display-{bank}.rrd"; path.write_bytes(payload[8:8 + length])
|
||||
chunks = list(RrdReader(path).stream())
|
||||
assert {c.entity_path for c in chunks} == {f"/world/display_points/{bank}"}
|
||||
assert sum(c.to_record_batch().num_rows for c in chunks) == 6
|
||||
assert command.primary_artifact.path.read_bytes() == original
|
||||
|
||||
|
||||
def test_stream_cancellation_releases_slot(tmp_path: Path):
|
||||
command = _command(tmp_path)
|
||||
def stream():
|
||||
return render_point_display(command, application_id="nodedc_mission_core_recorded",
|
||||
recording_id="recording-001", color_mode="intensity", palette="turbo", custom_color="#112233",
|
||||
point_decimation_percent=50, display_bank='0' * 32)
|
||||
first = stream(); next(first); first.close()
|
||||
second = stream(); assert next(second) == b'NPD1'; second.close()
|
||||
|
||||
|
||||
def test_every_batch_is_an_independently_decodable_rrd(tmp_path: Path, monkeypatch):
|
||||
from k1link.device_plugins.xgrids_k1 import recorded_point_display as module
|
||||
original = module._iter_point_frames
|
||||
def many_frames(*args, **kwargs):
|
||||
frames = list(original(*args, **kwargs))
|
||||
for _ in range(24):
|
||||
yield from frames
|
||||
monkeypatch.setattr(module, '_iter_point_frames', many_frames)
|
||||
parts = list(module.render_point_display(_command(tmp_path), application_id='nodedc_mission_core_recorded',
|
||||
recording_id='recording-001', color_mode='height', palette='viridis', custom_color='#112233',
|
||||
point_decimation_percent=5, display_bank='a' * 32))
|
||||
assert parts[0] == b'NPD1' and parts[-1] == b'\0' * 4
|
||||
rows = 0
|
||||
for i, part in enumerate(parts[1:-1]):
|
||||
length = struct.unpack('<I', part[:4])[0]
|
||||
assert length == len(part) - 4 and part[4:8] == b'RRF2'
|
||||
path = tmp_path / f'batch-{i}.rrd'; path.write_bytes(part[4:])
|
||||
rows += sum(chunk.to_record_batch().num_rows for chunk in RrdReader(path).stream())
|
||||
assert len(parts) >= 5
|
||||
assert rows == 144
|
||||
|
||||
|
||||
def test_prepared_rrd_keeps_exact_colors_geometry_and_86_2_percent(tmp_path, monkeypatch):
|
||||
import rerun as rr
|
||||
from k1link.device_plugins.xgrids_k1 import recorded_point_display as module
|
||||
from k1link.device_plugins.xgrids_k1.prepared_point_display import prepared_point_rows
|
||||
source = tmp_path / 'prepared.rrd'
|
||||
rec = rr.RecordingStream('nodedc_mission_core_recorded', recording_id='prepared-source')
|
||||
rec.save(source)
|
||||
xyz = np.arange(3000, dtype=np.float32).reshape(-1, 3)
|
||||
colors = np.arange(1000, dtype=np.uint32) * 256 + 255
|
||||
for seq in range(1, 4):
|
||||
rec.set_time('message_sequence', sequence=seq)
|
||||
rec.set_time('session_time', duration=np.timedelta64(seq * 100_000_000, 'ns'))
|
||||
rec.log('/world/points', rr.Points3D(xyz + seq, colors=colors))
|
||||
rec.flush(); rec.disconnect()
|
||||
frames = list(prepared_point_rows(source))
|
||||
assert len(frames) == 3
|
||||
np.testing.assert_array_equal(frames[0][2], xyz + 1)
|
||||
np.testing.assert_array_equal(frames[0][3], colors)
|
||||
monkeypatch.setattr(module, '_iter_point_frames', lambda *a, **k: pytest.fail('raw must not be decoded'))
|
||||
parts = list(module.render_point_display(_command(tmp_path),
|
||||
application_id='nodedc_mission_core_recorded', recording_id='prepared-source',
|
||||
color_mode='intensity', palette='turbo', custom_color='#112233',
|
||||
point_decimation_percent=86.2, display_bank='c' * 32, prepared_recording_path=source))
|
||||
result = tmp_path / 'result.rrd'; result.write_bytes(parts[1][4:])
|
||||
rows = []
|
||||
for chunk in RrdReader(result).stream():
|
||||
batch = chunk.to_record_batch()
|
||||
for i in range(batch.num_rows):
|
||||
rows.append((batch.column('session_time')[i].value,
|
||||
batch.column('Points3D:positions')[i].values.flatten().to_numpy().reshape(-1, 3),
|
||||
batch.column('Points3D:colors')[i].values.to_numpy()))
|
||||
for seq, (ns, positions, rgba) in enumerate(rows, start=1):
|
||||
indices = retained_indices(1000, 86.2, seq)
|
||||
assert len(positions) == 138
|
||||
assert ns == seq * 100_000_000
|
||||
np.testing.assert_array_equal(positions, (xyz + seq)[indices])
|
||||
np.testing.assert_array_equal(rgba, colors[indices])
|
||||
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from k1link.web.response_compression import ResponseCompressionMiddleware
|
||||
from k1link.web.session_api import _ReleasingFileResponse
|
||||
|
||||
|
||||
def test_rrd_preserves_bytes_length_etag_and_ranges_with_gzip_client(tmp_path: Path) -> None:
|
||||
payload = b"RRD compressed fixture" * 1024
|
||||
path = tmp_path / "scene.rrd"
|
||||
path.write_bytes(payload)
|
||||
app = FastAPI()
|
||||
app.add_middleware(ResponseCompressionMiddleware)
|
||||
releases = []
|
||||
|
||||
@app.get("/scene.rrd")
|
||||
def recording() -> FileResponse:
|
||||
return _ReleasingFileResponse(
|
||||
path,
|
||||
headers={"ETag": '"fixture-generation"'},
|
||||
release=lambda: releases.append(True),
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/scene.rrd?generation=fixture", headers={"Accept-Encoding": "gzip"})
|
||||
assert response.content == payload
|
||||
assert "content-encoding" not in response.headers
|
||||
assert int(response.headers["content-length"]) == len(payload)
|
||||
assert response.headers["etag"] == '"fixture-generation"'
|
||||
partial = client.get(
|
||||
"/scene.rrd", headers={"Accept-Encoding": "gzip", "Range": "bytes=100-2199"}
|
||||
)
|
||||
assert partial.status_code == 206
|
||||
assert partial.content == payload[100:2200]
|
||||
assert "content-encoding" not in partial.headers
|
||||
assert partial.headers["content-range"] == f"bytes 100-2199/{len(payload)}"
|
||||
assert partial.headers["content-length"] == "2100"
|
||||
assert releases == [True, True]
|
||||
assert _ReleasingFileResponse.chunk_size == 1024 * 1024
|
||||
|
||||
|
||||
def test_blueprint_and_color_posts_bypass_gzip_but_json_keeps_it() -> None:
|
||||
app = FastAPI()
|
||||
app.add_middleware(ResponseCompressionMiddleware)
|
||||
payload = b"native RRD" * 1024
|
||||
|
||||
@app.post("/{kind}.rrd")
|
||||
def overlay(kind: str) -> Response:
|
||||
return Response(payload, media_type="application/octet-stream")
|
||||
|
||||
@app.get("/metadata")
|
||||
def metadata() -> JSONResponse:
|
||||
return JSONResponse({"description": "a" * 8192})
|
||||
|
||||
with TestClient(app) as client:
|
||||
for kind in ("blueprint", "point-colors"):
|
||||
response = client.post(f"/{kind}.rrd", headers={"Accept-Encoding": "gzip"})
|
||||
assert response.content == payload
|
||||
assert "content-encoding" not in response.headers
|
||||
response = client.get("/metadata", headers={"Accept-Encoding": "gzip"})
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.json() == {"description": "a" * 8192}
|
||||
@@ -12,7 +12,7 @@ from k1link.missions.route_relocalization import (
|
||||
relocalize_route,
|
||||
relocalize_start_then_route,
|
||||
)
|
||||
from k1link.missions.stationary_bootstrap import BOOTSTRAP_POLICY, StationaryBootstrap
|
||||
from k1link.missions.stationary_bootstrap import StationaryBootstrap
|
||||
|
||||
|
||||
def scene():
|
||||
@@ -102,8 +102,8 @@ def test_middle_of_route_relocalisation_qualifies_with_real_gicp():
|
||||
assert not output["localization_confirmed"] and not output["vehicle_control"]
|
||||
|
||||
|
||||
def test_hybrid_accepts_the_dense_start_before_route_retrieval(monkeypatch):
|
||||
"""A normal start must not be degraded by global target voxelisation."""
|
||||
def test_hybrid_compares_dense_start_with_entire_route_before_accepting(monkeypatch):
|
||||
"""Preserve a precise start hypothesis, but never shortcut global ambiguity."""
|
||||
import k1link.missions.route_relocalization as module
|
||||
|
||||
reference, path = scene()
|
||||
@@ -112,15 +112,20 @@ def test_hybrid_accepts_the_dense_start_before_route_retrieval(monkeypatch):
|
||||
local["initialization"] = dict(complete=True, reason=None, attempts=[{}] * 108)
|
||||
monkeypatch.setattr(module, "acquire_entry", lambda *args, **kwargs: dict(local))
|
||||
|
||||
def unexpected_route(*args, **kwargs):
|
||||
raise AssertionError("A qualified dense start must not enter route recovery.")
|
||||
calls = []
|
||||
def route(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
hypotheses = kwargs["additional_hypotheses"]
|
||||
assert len(hypotheses) == 1
|
||||
return choose_route_location(hypotheses, [0, 0, 0], complete=True)
|
||||
|
||||
monkeypatch.setattr(module, "relocalize_route", unexpected_route)
|
||||
monkeypatch.setattr(module, "relocalize_route", route)
|
||||
output = relocalize_start_then_route(reference, path, query, [0.0, 0.0, 0.0])
|
||||
|
||||
assert output["status"] == "candidate"
|
||||
assert output["initialization"]["strategy"] == "dense-start-first-then-route-recovery/v1"
|
||||
assert output["initialization"]["expected_attempts"] == 108
|
||||
assert calls
|
||||
assert output["initialization"]["strategy"] == ROUTE_RELOCALIZATION_POLICY["strategy"]
|
||||
assert output["initialization"]["expected_attempts"] == 109
|
||||
assert output["initialization"]["stages"][0]["name"] == "dense-start"
|
||||
|
||||
|
||||
@@ -194,6 +199,7 @@ def test_global_result_must_account_for_every_generated_hypothesis_before_prior(
|
||||
boot.phase = "searching"
|
||||
boot.initialization_sample = dict(monotonic_ns=1, segment=0)
|
||||
boot.search_started_ns = 1
|
||||
boot.last_pose_ns = boot.last_cloud_ns = 1
|
||||
accepted = boot.offer_prior(
|
||||
dict(
|
||||
status="candidate",
|
||||
@@ -212,6 +218,7 @@ def test_global_result_must_account_for_every_generated_hypothesis_before_prior(
|
||||
second.phase = "searching"
|
||||
second.initialization_sample = dict(monotonic_ns=1, segment=0)
|
||||
second.search_started_ns = 1
|
||||
second.last_pose_ns = second.last_cloud_ns = 1
|
||||
initialization["expected_attempts"] = 3
|
||||
rejected = second.offer_prior(
|
||||
dict(
|
||||
@@ -236,13 +243,12 @@ def test_global_ambiguity_has_a_specific_operator_message():
|
||||
assert "несколько похожих участков" in phase_message(boot)
|
||||
|
||||
|
||||
def test_route_search_deadline_cannot_outlive_the_stationary_prefix_freshness_fence():
|
||||
assert (
|
||||
ROUTE_RELOCALIZATION_POLICY["deadline_s"] < BOOTSTRAP_POLICY["maximum_prior_source_age_s"]
|
||||
)
|
||||
assert (
|
||||
ROUTE_RELOCALIZATION_POLICY["maximum_search_wall_s"]
|
||||
<= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]
|
||||
def test_route_search_separates_numerical_progress_from_current_receipt_freshness():
|
||||
assert "deadline_s" not in ROUTE_RELOCALIZATION_POLICY
|
||||
assert "maximum_search_wall_s" not in ROUTE_RELOCALIZATION_POLICY
|
||||
assert ROUTE_RELOCALIZATION_POLICY["worker_stall_s"] > 0
|
||||
assert ROUTE_RELOCALIZATION_POLICY["hypothesis_freshness"] == (
|
||||
"stationary-receipts-and-disjoint-confirmation/v1"
|
||||
)
|
||||
|
||||
|
||||
@@ -263,7 +269,7 @@ def test_precise_search_reaches_the_last_ranked_anchor(monkeypatch):
|
||||
def register(self, query, initial, *, policy):
|
||||
calls.append(policy)
|
||||
result = candidate_result(np.eye(4))
|
||||
if len(calls) < (len(ranked) - 1) * 3 + 1:
|
||||
if len(calls) < (len(ranked) - 1) * 6 + 1:
|
||||
result.update(status="rejected", reasons=["fixture mismatch"])
|
||||
return result
|
||||
|
||||
@@ -273,14 +279,14 @@ def test_precise_search_reaches_the_last_ranked_anchor(monkeypatch):
|
||||
info = result["initialization"]
|
||||
assert result["status"] == "candidate"
|
||||
assert info["selected_candidate_index"] == ranked[-1].index
|
||||
assert len(calls) == info["expected_attempts"] == len(ranked) * 3
|
||||
assert len(calls) == info["expected_attempts"] == len(ranked) * 6
|
||||
assert len(prepared_targets) == len(ranked)
|
||||
assert not info["remaining_candidate_indices"]
|
||||
assert len(info["candidate_batches"]) > 1
|
||||
assert all(policy == ROUTE_RELOCALIZATION_POLICY["registration_policy"] for policy in calls)
|
||||
|
||||
|
||||
def test_budget_exhaustion_is_not_a_completed_negative_or_provisional_fit(monkeypatch):
|
||||
def test_elapsed_time_does_not_discard_the_rest_of_the_finite_queue(monkeypatch):
|
||||
import k1link.missions.route_relocalization as module
|
||||
|
||||
reference, path = scene()
|
||||
@@ -294,11 +300,39 @@ def test_budget_exhaustion_is_not_a_completed_negative_or_provisional_fit(monkey
|
||||
result = relocalize_route(reference, path, reference[:700], [0, 0, 0],
|
||||
clock=lambda: elapsed[0])
|
||||
info = result["initialization"]
|
||||
assert result["reasons"] == ["incomplete-route-search"]
|
||||
assert not info["complete"] and not info["candidate_queue_exhausted"]
|
||||
assert info["remaining_candidate_indices"]
|
||||
assert info["expected_attempts"] > len(info["attempts"])
|
||||
assert info["candidate_queue"] == []
|
||||
assert result["status"] == "candidate"
|
||||
assert elapsed[0] > 120
|
||||
assert info["complete"] and info["candidate_queue_exhausted"]
|
||||
assert not info["remaining_candidate_indices"]
|
||||
assert info["expected_attempts"] == len(info["attempts"])
|
||||
assert info["candidate_queue"]
|
||||
|
||||
|
||||
def test_anchor_seeds_map_the_sensor_origin_not_the_cloud_median(monkeypatch):
|
||||
import k1link.missions.route_relocalization as module
|
||||
reference, path = scene()
|
||||
entry = np.array([1.0, 2.0, 0.2])
|
||||
monkeypatch.setattr(module.PreparedReference, "register",
|
||||
lambda self, query, initial, **kwargs: candidate_result(initial))
|
||||
result = relocalize_route(reference, path, reference[:700], entry)
|
||||
for item in result["initialization"]["attempts"]:
|
||||
if item["seed_mode"] != "pose-anchor":
|
||||
continue
|
||||
matrix = np.asarray(item["result"]["initial_T_reference_query"])
|
||||
assert np.allclose(matrix[:3, :3] @ entry + matrix[:3, 3], item["candidate"]["position"])
|
||||
|
||||
|
||||
def test_dense_start_cannot_hide_an_equally_good_distant_place(monkeypatch):
|
||||
import k1link.missions.route_relocalization as module
|
||||
reference, path = scene()
|
||||
dense = attempt(-1, 0, np.eye(4))
|
||||
other = np.eye(4)
|
||||
other[0, 3] = 80
|
||||
monkeypatch.setattr(module.PreparedReference, "register",
|
||||
lambda *args, **kwargs: candidate_result(other))
|
||||
result = relocalize_route(reference, path, reference[:700], [0, 0, 0],
|
||||
additional_hypotheses=[dense])
|
||||
assert result["reasons"] == ["ambiguous-route-location"]
|
||||
|
||||
|
||||
def test_ambiguity_uses_fitted_places_even_when_retrieval_anchor_is_same():
|
||||
@@ -319,3 +353,14 @@ def test_fresh_confirmation_fallback_skips_dense_search(monkeypatch):
|
||||
monkeypatch.setattr(module, "relocalize_route", lambda *a, **k: {"route_only": True})
|
||||
assert relocalize_start_then_route(reference, path, reference[:700], [0, 0, 0],
|
||||
route_only=True) == {"route_only": True}
|
||||
|
||||
|
||||
def test_sparse_start_patch_does_not_prevent_searching_the_rest_of_the_map(monkeypatch):
|
||||
import k1link.missions.route_relocalization as module
|
||||
reference, path = scene()
|
||||
def no_start(*args, **kwargs):
|
||||
raise module.ReferenceCoverageError("sparse start")
|
||||
monkeypatch.setattr(module, "_route_start_context", no_start)
|
||||
monkeypatch.setattr(module, "relocalize_route", lambda *a, **k: {"initialization": {}})
|
||||
result = relocalize_start_then_route(reference, path, reference[:700], [0, 0, 0])
|
||||
assert result["initialization"]["dense_start_unavailable"] == "sparse start"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Progress-bound child lifetime: no map-size timeout and no orphan on STOP."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.missions.route_relocalization_worker import supervise_search
|
||||
|
||||
|
||||
class Child:
|
||||
args = ["synthetic-search"]
|
||||
|
||||
def __init__(self, clock, progress, *, advance=True, cancel=None, stubborn=False):
|
||||
self.clock, self.progress = clock, progress
|
||||
self.advance, self.cancel, self.stubborn = advance, cancel, stubborn
|
||||
self.returncode = None
|
||||
self.steps = 0
|
||||
self.terminated = self.killed = False
|
||||
self.reaped = False
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
if self.terminated or self.killed:
|
||||
if self.stubborn and not self.killed:
|
||||
raise subprocess.TimeoutExpired(self.args, timeout)
|
||||
self.returncode, self.reaped = -15, True
|
||||
return self.returncode
|
||||
self.clock[0] += 15
|
||||
self.steps += 1
|
||||
if self.advance:
|
||||
self.progress.write_text(str(self.steps))
|
||||
if self.cancel is not None and self.steps == 3:
|
||||
self.cancel.set()
|
||||
if self.steps == 20:
|
||||
self.returncode = 0
|
||||
return 0
|
||||
raise subprocess.TimeoutExpired(self.args, timeout)
|
||||
|
||||
def terminate(self):
|
||||
self.terminated = True
|
||||
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
|
||||
|
||||
def test_progressing_search_outlives_old_total_timeout(tmp_path):
|
||||
clock = [0.]
|
||||
progress = tmp_path / "progress.json"
|
||||
child = Child(clock, progress)
|
||||
assert supervise_search(child, progress, clock=lambda: clock[0]) is None
|
||||
assert clock[0] == 300 and not child.terminated
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel,stubborn", [(False, False), (True, False), (True, True)])
|
||||
def test_stall_or_stop_reaps_exact_child_before_return(tmp_path, cancel, stubborn):
|
||||
clock = [0.]
|
||||
progress = tmp_path / "progress.json"
|
||||
event = threading.Event() if cancel else None
|
||||
child = Child(clock, progress, advance=cancel, cancel=event, stubborn=stubborn)
|
||||
reason = supervise_search(child, progress, cancel_event=event, clock=lambda: clock[0])
|
||||
assert reason == ("worker-cancelled" if cancel else "worker-stalled")
|
||||
assert child.terminated and child.reaped
|
||||
assert child.killed == stubborn
|
||||
+45
-13
@@ -25,9 +25,6 @@ from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
RECORDED_POINTS_VISUALIZER_ID,
|
||||
RECORDED_ROOT_CONTAINER_ID,
|
||||
RECORDED_SPATIAL_VIEW_ID,
|
||||
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD,
|
||||
RECORDED_VIEW_POINT_FRAME_STRIDE,
|
||||
RECORDED_VIEW_POINT_STRIDE,
|
||||
SESSION_TIMELINE,
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
@@ -59,9 +56,8 @@ def _point_payload(x: float) -> bytes:
|
||||
)
|
||||
|
||||
|
||||
def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
|
||||
assert RECORDED_VIEW_POINT_FRAME_STRIDE == 5
|
||||
count = RECORDED_VIEW_POINT_DECIMATION_THRESHOLD + 3
|
||||
def test_recorded_operator_projection_preserves_all_points_and_attributes() -> None:
|
||||
count = 100_003
|
||||
positions = export_module.np.arange(
|
||||
count * 3,
|
||||
dtype=export_module.np.float32,
|
||||
@@ -78,16 +74,16 @@ def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
|
||||
assert projected_rgb is not None
|
||||
assert export_module.np.array_equal(
|
||||
projected_positions,
|
||||
positions[::RECORDED_VIEW_POINT_STRIDE],
|
||||
positions,
|
||||
)
|
||||
assert export_module.np.array_equal(
|
||||
projected_intensities,
|
||||
intensities[::RECORDED_VIEW_POINT_STRIDE],
|
||||
intensities,
|
||||
)
|
||||
assert export_module.np.array_equal(projected_rgb, rgb[::RECORDED_VIEW_POINT_STRIDE])
|
||||
assert export_module.np.array_equal(projected_rgb, rgb)
|
||||
|
||||
small_positions = positions[:RECORDED_VIEW_POINT_DECIMATION_THRESHOLD]
|
||||
small_intensities = intensities[:RECORDED_VIEW_POINT_DECIMATION_THRESHOLD]
|
||||
small_positions = positions[:2400]
|
||||
small_intensities = intensities[:2400]
|
||||
same_positions, same_intensities, no_rgb = _recorded_view_points(
|
||||
small_positions,
|
||||
small_intensities,
|
||||
@@ -98,18 +94,54 @@ def test_recorded_operator_projection_thins_only_dense_point_batches() -> None:
|
||||
assert no_rgb is None
|
||||
|
||||
|
||||
def test_recorded_operator_projection_uses_stable_two_hz_point_cadence() -> None:
|
||||
def test_recorded_operator_projection_preserves_every_point_frame() -> None:
|
||||
published = [
|
||||
frame_number
|
||||
for frame_number in range(1, 13)
|
||||
if _should_publish_recorded_point_frame(frame_number)
|
||||
]
|
||||
|
||||
assert published == [1, 6, 11]
|
||||
assert published == list(range(1, 13))
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
_should_publish_recorded_point_frame(0)
|
||||
|
||||
|
||||
def test_export_logs_every_frame_not_just_counts_them(tmp_path: Path, monkeypatch) -> None:
|
||||
capture = _write_capture(tmp_path, [
|
||||
("RealtimePointcloud", _point_payload(float(index)), index * 100_000_000)
|
||||
for index in range(12)
|
||||
])
|
||||
logged_counts = []
|
||||
original_log_points = export_module._log_points
|
||||
|
||||
def log_points(recording, frame, settings, **kwargs):
|
||||
logged_counts.append(frame.point_count)
|
||||
return original_log_points(recording, frame, settings, **kwargs)
|
||||
|
||||
monkeypatch.setattr(export_module, "_log_points", log_points)
|
||||
summary = export_k1mqtt_to_rrd(capture, tmp_path / "all-frames.rrd")
|
||||
assert logged_counts == [1] * 12
|
||||
assert sum(logged_counts) == summary["points"]
|
||||
|
||||
|
||||
def test_thirty_minute_settings_keep_small_screen_space_points() -> None:
|
||||
blueprint = viewer_recorded_blueprint(
|
||||
RerunSceneSettings(point_size=0.5, accumulation_seconds=1800),
|
||||
update_eye_controls=False,
|
||||
)
|
||||
spatial_view = blueprint.root_container.contents[0]
|
||||
_, visualizer, ranges = spatial_view.visualizer_overrides["/world/points"]
|
||||
values = {
|
||||
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
|
||||
for batch in visualizer.overrides
|
||||
}
|
||||
assert values["Points3D:radii"] == [-0.5]
|
||||
assert ranges.ranges.as_arrow_array().to_pylist()[0]["range"] == {
|
||||
"start": -1_800_000_000_000, "end": 0,
|
||||
}
|
||||
assert "EyeControls3D" not in spatial_view.properties
|
||||
|
||||
|
||||
def _pose_payload(x: float) -> bytes:
|
||||
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
|
||||
from k1link.sessions import SessionStore
|
||||
from k1link.sessions.display_profile import load_display_profile, save_display_profile
|
||||
from k1link.web.session_api import build_session_router
|
||||
from test_session_api import make_legacy_session
|
||||
|
||||
|
||||
def settings(**patch):
|
||||
return dict(projection="3d", point_size=0.5, color_mode="height", palette="viridis",
|
||||
custom_color="#112233", accumulation_seconds=600, accumulation_max_seconds=600,
|
||||
point_decimation_percent=49.5, show_points=True, show_trajectory=True,
|
||||
show_grid=True, show_labels=False, show_camera_frustums=True, **patch)
|
||||
|
||||
|
||||
def test_atomic_session_metadata_keeps_evidence_and_isolates_sessions(tmp_path: Path):
|
||||
a, b = tmp_path / "a", tmp_path / "b"
|
||||
a.mkdir(); b.mkdir()
|
||||
(a / "method.json").write_text('{"immutable":true}')
|
||||
assert load_display_profile(a, "a") is None
|
||||
save_display_profile(a, "a", settings())
|
||||
assert load_display_profile(a, "a")["scene_settings"] == settings()
|
||||
assert load_display_profile(b, "b") is None
|
||||
assert (a / "method.json").read_text() == '{"immutable":true}'
|
||||
assert not list(a.glob(".display-profile-*"))
|
||||
with pytest.raises(ValueError):
|
||||
load_display_profile(a, "b")
|
||||
|
||||
|
||||
def test_profile_symlink_is_not_followed(tmp_path: Path):
|
||||
other = tmp_path / "other.json"
|
||||
other.write_text("original")
|
||||
(tmp_path / "display-profile.json").symlink_to(other)
|
||||
with pytest.raises(ValueError):
|
||||
save_display_profile(tmp_path, "a", settings())
|
||||
assert other.read_text() == "original"
|
||||
|
||||
|
||||
def test_failed_atomic_replace_keeps_previous_profile(tmp_path: Path, monkeypatch):
|
||||
save_display_profile(tmp_path, 'a', settings())
|
||||
previous = (tmp_path / 'display-profile.json').read_bytes()
|
||||
def fail(*args):
|
||||
raise OSError('disk unavailable')
|
||||
monkeypatch.setattr('k1link.sessions.display_profile.os.replace', fail)
|
||||
changed = settings(); changed['point_decimation_percent'] = 80
|
||||
with pytest.raises(OSError): save_display_profile(tmp_path, 'a', changed)
|
||||
assert (tmp_path / 'display-profile.json').read_bytes() == previous
|
||||
assert not list(tmp_path.glob('.display-profile-*'))
|
||||
|
||||
|
||||
def test_profile_api_roundtrip_strict_validation_and_no_capture_mutation(tmp_path: Path):
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260921T134309Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
app = FastAPI(); app.include_router(build_session_router(store))
|
||||
url = f"/api/v1/observation-sessions/{session.name}/display-profile"
|
||||
with TestClient(app) as client:
|
||||
assert client.get(url).json() is None
|
||||
response = client.put(url, json={"scene_settings": settings()})
|
||||
assert response.status_code == 200
|
||||
assert client.get(url).json() == response.json()
|
||||
assert json.loads((session / "display-profile.json").read_text()) == response.json()
|
||||
for value in (-1, 100.1):
|
||||
invalid = settings(); invalid["point_decimation_percent"] = value
|
||||
assert client.put(url, json={"scene_settings": invalid}).status_code == 422
|
||||
for value in (0, 50, 100):
|
||||
valid = settings(); valid["point_decimation_percent"] = value
|
||||
assert client.put(url, json={"scene_settings": valid}).status_code == 200
|
||||
assert client.put(url, json={"scene_settings": settings(), "path": "/tmp"}).status_code == 422
|
||||
assert client.get("/api/v1/observation-sessions/missing/display-profile").status_code == 404
|
||||
|
||||
|
||||
def test_point_display_pins_exact_prepared_generation_and_releases_on_error(tmp_path):
|
||||
from types import SimpleNamespace
|
||||
repository = tmp_path / 'repo'
|
||||
session = make_legacy_session(repository / 'sessions', '20260921T134309Z_viewer_live')
|
||||
store = SessionStore(repository, data_dir=tmp_path / 'data')
|
||||
store.reconcile_archive(xgrids_k1_archive_source(repository / 'sessions'))
|
||||
command = store.prepare_replay(session.name)
|
||||
path = tmp_path / 'prepared.rrd'
|
||||
snapshot = SimpleNamespace(state='ready', preparation_id='prep-1', command=command,
|
||||
recording=SimpleNamespace(path=path, sha256='a' * 64))
|
||||
releases, calls = [], []
|
||||
manager = SimpleNamespace(status=lambda _: snapshot,
|
||||
pin_ready=lambda *a, **k: (snapshot, lambda: releases.append(True)))
|
||||
fail = False
|
||||
def renderer(command, **kwargs):
|
||||
calls.append(kwargs)
|
||||
assert kwargs['prepared_recording_path'] == path
|
||||
assert 'source_generation' not in kwargs
|
||||
if fail:
|
||||
raise ValueError('bad prepared source')
|
||||
yield b'NPD1'
|
||||
yield b'\0' * 4
|
||||
app = FastAPI()
|
||||
app.include_router(build_session_router(store, recording_preparation_manager=manager,
|
||||
point_display_renderers={command.plugin_id: renderer}))
|
||||
url = f'/api/v1/observation-sessions/{session.name}/point-display.rrd'
|
||||
request = dict(application_id='nodedc_mission_core_recorded', recording_id='test-recording',
|
||||
color_mode='intensity', palette='turbo', custom_color='#112233',
|
||||
point_decimation_percent=86.2, display_bank='b' * 32, source_generation='a' * 64)
|
||||
with TestClient(app) as client:
|
||||
assert client.post(url, json={**request, 'source_generation': 'f' * 64}).status_code == 412
|
||||
assert not calls and not releases
|
||||
result = client.post(url, json=request)
|
||||
assert result.status_code == 200 and result.content == b'NPD1' + b'\0' * 4
|
||||
assert len(releases) == 1
|
||||
fail = True
|
||||
assert client.post(url, json=request).status_code == 409
|
||||
assert len(releases) == 2
|
||||
@@ -0,0 +1,222 @@
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from rerun.experimental import RrdReader
|
||||
from test_map_reference_version import fixture as map_fixture
|
||||
from test_rrd_export import _point_payload, _pose_payload, _write_capture
|
||||
from test_session_recording import _command
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import export_k1mqtt_to_rrd
|
||||
from k1link.missions.default_sources import DefaultPlanningSources
|
||||
from k1link.missions.drafts import MissionDrafts
|
||||
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
|
||||
from k1link.reconstruction.session_versions import SessionMapVersions
|
||||
from k1link.sessions.map_recording import MapReplaySessionStore
|
||||
from k1link.sessions.models import ReplayMapVersion, SessionIntegrityError
|
||||
from k1link.sessions.preparation import _source_identity
|
||||
from k1link.sessions.recording import (
|
||||
RecordingMaterializationError,
|
||||
_validate_source,
|
||||
_validated_artifact_digests,
|
||||
)
|
||||
from k1link.web.mission_planner_api import build_mission_planner_router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path):
|
||||
return map_fixture.__wrapped__(tmp_path)
|
||||
|
||||
|
||||
def sources(f):
|
||||
f.original.store = SimpleNamespace(data_dir=f.root)
|
||||
f.original.root = f.root / "planning"
|
||||
versions = SessionMapVersions(f.root / "data")
|
||||
return DefaultPlanningSources(f.original, versions), versions
|
||||
|
||||
|
||||
def test_default_is_explicit_durable_and_preserves_original_and_old_draft(fixture):
|
||||
f = fixture
|
||||
planning, versions = sources(f)
|
||||
version = f.publish()
|
||||
assert planning.get("source-a")["generation"] == "a" * 64
|
||||
drafts = MissionDrafts(f.root / "drafts", planning)
|
||||
args = dict(
|
||||
session_id="source-a",
|
||||
generation="a" * 64,
|
||||
id=None,
|
||||
revision=0,
|
||||
name="Before correction",
|
||||
start_index=0,
|
||||
end_index=2,
|
||||
direction="forward",
|
||||
)
|
||||
old = drafts.save(SimpleNamespace(**args))
|
||||
versions.activate(version, f.original)
|
||||
current = planning.get("source-a")
|
||||
assert current["generation"] == version.generation
|
||||
assert current["label"] == f.source["label"] # ordinary catalog name
|
||||
np.testing.assert_array_equal([p["position"] for p in current["poses"]], f.corrected)
|
||||
new = drafts.save(SimpleNamespace(**{**args, "generation": current["generation"]}))
|
||||
assert new["zone"]["generation"] != old["zone"]["generation"]
|
||||
assert drafts.get(old["id"]) == old
|
||||
assert drafts.check(old["id"], 1)["vehicle_control"] is False
|
||||
assert SessionMapVersions(f.root / "data").selected("source-a").generation == version.generation
|
||||
assert planning.bound("source-a", "a" * 64)["poses"] == f.source["poses"]
|
||||
points, metadata = planning.submap("source-a", current["generation"], 0, 2)
|
||||
np.testing.assert_array_equal(points, f.corrected)
|
||||
assert metadata["generation"] == current["generation"]
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(build_mission_planner_router(drafts))
|
||||
with TestClient(app) as client:
|
||||
url = "/api/v1/mission-planner/sources/source-a"
|
||||
assert client.get(url).json()["generation"] == version.generation
|
||||
assert client.get(url, params={"generation": "a" * 64}).json()["generation"] == "a" * 64
|
||||
assert client.get(url, params={"generation": "c" * 64}).status_code == 409
|
||||
|
||||
|
||||
def test_missing_or_corrupt_default_never_silently_becomes_original(fixture):
|
||||
f = fixture
|
||||
planning, versions = sources(f)
|
||||
version = versions.activate(f.publish(), f.original)
|
||||
file = version.directory / "points.f32"
|
||||
original = file.read_bytes()
|
||||
file.write_bytes(b"x" * len(original))
|
||||
with pytest.raises(ValueError, match="identity changed"):
|
||||
planning.get("source-a")
|
||||
file.write_bytes(original)
|
||||
version.directory.rename(version.directory.with_name("removed-version"))
|
||||
with pytest.raises(ValueError, match="not substituted"):
|
||||
planning.get("source-a")
|
||||
|
||||
|
||||
def test_replay_binding_keeps_native_root_and_raw_identity(fixture):
|
||||
f = fixture
|
||||
command = _command(f.root / "capture")
|
||||
f.source.update(
|
||||
session_id=command.session_id,
|
||||
source_digests=_validated_artifact_digests(_validate_source(command)),
|
||||
)
|
||||
# The fixture parent is source-a-specific; the replay binding itself verifies
|
||||
# real native digests independently of planning-source fixtures.
|
||||
version = f.publish()
|
||||
pinned = replace(command, map_version=ReplayMapVersion(version.directory, version.generation))
|
||||
source = _validate_source(pinned)
|
||||
assert pinned.session_root == command.session_root
|
||||
assert source.primary.path == command.primary_artifact.path
|
||||
assert len(source.artifacts) == len(command.artifacts) + 3
|
||||
assert _source_identity(pinned) != _source_identity(command)
|
||||
assert (
|
||||
_validated_artifact_digests(source)[command.primary_artifact_id]
|
||||
== f.source["source_digests"][command.primary_artifact_id]
|
||||
)
|
||||
wrong = replace(pinned, session_id="some-other-session")
|
||||
with pytest.raises(RecordingMaterializationError):
|
||||
_validate_source(wrong)
|
||||
facade = MapReplaySessionStore(
|
||||
SimpleNamespace(prepare_replay=lambda *_: command),
|
||||
SimpleNamespace(resolve_replay=lambda _: wrong),
|
||||
)
|
||||
with pytest.raises(SessionIntegrityError):
|
||||
facade.prepare_replay(command.session_id)
|
||||
|
||||
|
||||
def test_projector_owns_full_frames_pose_orientation_and_clock(fixture):
|
||||
f = fixture
|
||||
version = f.publish()
|
||||
geometry = RecordedMapGeometry(
|
||||
{
|
||||
"map-version-" + name: version.directory / name
|
||||
for name in ("manifest.json", "points.f32", "trajectory.npz")
|
||||
}
|
||||
)
|
||||
for i in range(3):
|
||||
np.testing.assert_array_equal(geometry.points(i, i, 2), f.points[i * 2 : i * 2 + 2])
|
||||
np.testing.assert_array_equal(geometry.pose(i, i)[0], f.corrected[i])
|
||||
geometry.complete(3, 3)
|
||||
with pytest.raises(ValueError, match="clock"):
|
||||
geometry.pose(0, 0.5)
|
||||
with pytest.raises(ValueError, match="ownership"):
|
||||
geometry.points(0, 0, 3)
|
||||
with pytest.raises(ValueError, match="all native"):
|
||||
geometry.complete(2, 3)
|
||||
assert geometry.recording_id("original") != "original"
|
||||
|
||||
|
||||
def test_overview_default_follows_map_but_original_comparison_stays_available(fixture):
|
||||
from k1link.sessions.overview import SessionOverviewService
|
||||
|
||||
f = fixture
|
||||
_, versions = sources(f)
|
||||
version = versions.activate(f.publish(), f.original)
|
||||
service = SessionOverviewService(SimpleNamespace(data_dir=f.root), {}, map_versions=versions)
|
||||
pair = SimpleNamespace(
|
||||
document={
|
||||
"map_generation": version.generation,
|
||||
"source_digests": f.source["source_digests"],
|
||||
}
|
||||
)
|
||||
try:
|
||||
assert service.default_representation("source-a", pair) == "corrected"
|
||||
assert service.default_representation("source-a", pair, version.generation) == "corrected"
|
||||
assert service.default_representation("source-a", pair, "a" * 64) == "original"
|
||||
with pytest.raises(ValueError, match="unavailable"):
|
||||
service.default_representation("source-a", None)
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_corrected_rrd_retains_clock_and_replaces_cloud_pose_and_route(tmp_path):
|
||||
frames = [("unrelated", b"{}", 2_000_000_000)]
|
||||
for i in range(6):
|
||||
frames += [
|
||||
("RealtimePointcloud", _point_payload(i), 2_100_000_000 + i * 200_000_000),
|
||||
("RealtimePath", _pose_payload(i), 2_200_000_000 + i * 200_000_000),
|
||||
]
|
||||
raw = _write_capture(tmp_path, frames, capture_clock_offsets_ns=(0, 4_000_000_000))
|
||||
|
||||
class Geometry:
|
||||
def recording_id(self, original):
|
||||
return "corrected-" + original
|
||||
|
||||
def points(self, index, time, count):
|
||||
assert count == 1 and time == pytest.approx(0.1 + index * 0.2)
|
||||
return np.array([[100 + index, 200, 300]], dtype=np.float32)
|
||||
|
||||
def pose(self, index, time):
|
||||
assert time == pytest.approx(0.2 + index * 0.2)
|
||||
return (100.0 + index, 200.0, 300.0), (0.0, 0.0, 1.0, 0.0)
|
||||
|
||||
def complete(self, clouds, poses):
|
||||
assert (clouds, poses) == (6, 6)
|
||||
|
||||
original = export_k1mqtt_to_rrd(raw, tmp_path / "original.rrd")
|
||||
corrected = export_k1mqtt_to_rrd(raw, tmp_path / "corrected.rrd", map_geometry=Geometry())
|
||||
assert corrected["source_sha256"] == original["source_sha256"]
|
||||
assert corrected["timeline_start_ns"] == original["timeline_start_ns"] == 0
|
||||
assert corrected["timeline_end_ns"] == original["timeline_end_ns"] == 4_000_000_000
|
||||
assert corrected["recording_id"] != original["recording_id"]
|
||||
clouds, poses, route = [], [], []
|
||||
for chunk in RrdReader(tmp_path / "corrected.rrd").stream():
|
||||
batch = chunk.to_record_batch()
|
||||
if chunk.entity_path == "/world/points":
|
||||
clouds.extend(batch.column("Points3D:positions").to_pylist())
|
||||
if (
|
||||
chunk.entity_path == "/world/sensor_pose"
|
||||
and "Transform3D:translation" in batch.schema.names
|
||||
):
|
||||
poses.extend(batch.column("Transform3D:translation").to_pylist())
|
||||
if chunk.entity_path == "/world/trajectory" and "LineStrips3D:strips" in batch.schema.names:
|
||||
route.extend(batch.column("LineStrips3D:strips").to_pylist())
|
||||
np.testing.assert_array_equal(
|
||||
np.array(clouds).reshape(-1, 3), [[100 + index, 200, 300] for index in range(6)]
|
||||
)
|
||||
assert poses and np.array(poses).reshape(-1, 3)[-1, 0] == 105
|
||||
# Existing operator cadence is preserved; every published route vertex is corrected.
|
||||
np.testing.assert_array_equal(
|
||||
np.array(route[-1]).reshape(-1, 3), [[100, 200, 300], [103, 200, 300]]
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("scipy")
|
||||
from scipy.spatial.transform import Rotation
|
||||
|
||||
from k1link.reconstruction.smooth_correction import (
|
||||
CorrectionField,
|
||||
CorrectionPolicy,
|
||||
SurfaceLink,
|
||||
fit_correction,
|
||||
)
|
||||
|
||||
|
||||
def link(a=0, b=100, shift=(0, 0, 1), rotation=(0, 0, 0), identity="seam"):
|
||||
t = np.eye(4)
|
||||
t[:3, 3] = shift
|
||||
t[:3, :3] = Rotation.from_rotvec(rotation).as_matrix()
|
||||
return SurfaceLink(a, b, t, np.array([0.0, 0.0, 0.0]), 0.03, 0.05, identity)
|
||||
|
||||
|
||||
def test_smooth_seam_preserves_real_endpoint_offset_and_source():
|
||||
source = np.array([[0.0, 0.0, 0.0], [0.5, 0, -1]])
|
||||
saved = source.copy()
|
||||
field, report = fit_correction(100, [link()])
|
||||
corrected = field.points(source, [0, 100])
|
||||
assert report["converged"] and not report["production_promotion"]
|
||||
np.testing.assert_array_equal(source, saved)
|
||||
np.testing.assert_allclose(corrected[0], source[0], atol=1e-12)
|
||||
assert corrected[-1, 0] == pytest.approx(0.5) # NOT forced to the first pose
|
||||
assert abs(corrected[-1, 2]) < 0.001
|
||||
gradient = np.diff(field.points(np.zeros((101, 3)), np.arange(101)), axis=0)
|
||||
assert np.linalg.norm(gradient, axis=1).max() < 0.011
|
||||
|
||||
|
||||
def test_each_frame_is_rigid_and_orientation_is_corrected():
|
||||
field, _ = fit_correction(100, [link(rotation=(0.01, -0.02, 0.03))])
|
||||
p = np.random.default_rng(5).normal(size=(500, 3))
|
||||
changed = field.points(p, 45)
|
||||
np.testing.assert_allclose(
|
||||
np.linalg.norm(np.diff(changed, axis=0), axis=1),
|
||||
np.linalg.norm(np.diff(p, axis=0), axis=1),
|
||||
atol=1e-12,
|
||||
)
|
||||
pos, q = field.poses(np.zeros((1, 3)), [[0, 0, 0, 1]], [45])
|
||||
np.testing.assert_allclose(
|
||||
Rotation.from_quat(q).as_matrix(), field.matrices([45])[:, :3, :3], atol=1e-12
|
||||
)
|
||||
np.testing.assert_allclose(pos, field.matrices([45])[:, :3, 3])
|
||||
|
||||
|
||||
def test_multiple_loop_constraints_and_no_local_jump():
|
||||
field, report = fit_correction(
|
||||
100, [link(b=50, shift=(0, 0, 0.4)), link(a=50, shift=(0, 0, 0.6), identity="second")]
|
||||
)
|
||||
assert report["converged"]
|
||||
heights = field.points(np.zeros((3, 3)), [0, 50, 100])[:, 2]
|
||||
np.testing.assert_allclose(heights, [0, 0.4, 1], atol=0.002)
|
||||
eps = 1e-5
|
||||
for knot in field.knots[1:-1]:
|
||||
np.testing.assert_allclose(
|
||||
field.spline(knot - eps, 1), field.spline(knot + eps, 1), atol=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_zero_closure_is_identity_and_no_constraints_fail():
|
||||
field, _ = fit_correction(100, [link(shift=(0, 0, 0))])
|
||||
np.testing.assert_allclose(field.matrices(50)[0], np.eye(4))
|
||||
with pytest.raises(ValueError):
|
||||
fit_correction(100, [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), -1, 101])
|
||||
def test_never_extrapolates(bad):
|
||||
field = CorrectionField([0, 100], np.zeros((2, 6)))
|
||||
with pytest.raises(ValueError):
|
||||
field.matrices(bad)
|
||||
|
||||
|
||||
def test_invalid_policy_and_link():
|
||||
with pytest.raises(ValueError):
|
||||
CorrectionPolicy(knot_spacing_m=0)
|
||||
with pytest.raises(ValueError):
|
||||
SurfaceLink(0, 0, np.eye(4), np.zeros(3), 0.1, 0.1, "bad")
|
||||
with pytest.raises(ValueError):
|
||||
fit_correction(20, [link()])
|
||||
|
||||
|
||||
def test_world_coordinate_change_does_not_change_physical_correction():
|
||||
edge = link(rotation=(0.01, -0.02, 0.03))
|
||||
field, _ = fit_correction(100, [edge])
|
||||
h = np.eye(4)
|
||||
h[:3, :3] = Rotation.from_rotvec([0.3, -0.2, 0.1]).as_matrix()
|
||||
h[:3, 3] = [1000, -2000, 3000]
|
||||
changed = SurfaceLink(
|
||||
0,
|
||||
100,
|
||||
h @ edge.T_reference_query @ np.linalg.inv(h),
|
||||
h[:3, :3] @ edge.query_center + h[:3, 3],
|
||||
0.03,
|
||||
0.05,
|
||||
"seam",
|
||||
)
|
||||
other, _ = fit_correction(100, [changed])
|
||||
p = np.random.default_rng(3).normal(size=(21, 3))
|
||||
s = np.linspace(0, 100, len(p))
|
||||
np.testing.assert_allclose(
|
||||
other.points(p @ h[:3, :3].T + h[:3, 3], s),
|
||||
field.points(p, s) @ h[:3, :3].T + h[:3, 3],
|
||||
atol=2e-5,
|
||||
)
|
||||
|
||||
|
||||
def test_links_own_validated_arrays():
|
||||
matrix, center = np.eye(4), np.zeros(3)
|
||||
edge = SurfaceLink(0, 10, matrix.tolist(), center, 0.1, 0.1, "test")
|
||||
center[:] = np.nan
|
||||
assert np.isfinite(edge.query_center).all()
|
||||
with pytest.raises(ValueError):
|
||||
edge.T_reference_query[0, 0] = 2
|
||||
fit_correction(10, [edge])
|
||||
@@ -44,6 +44,77 @@ def initialized():
|
||||
return boot, result
|
||||
|
||||
|
||||
def long_route_search():
|
||||
from k1link.missions.route_relocalization import ROUTE_RELOCALIZATION_POLICY
|
||||
boot, result = initialized()
|
||||
boot.initialization_policy = ROUTE_RELOCALIZATION_POLICY
|
||||
result["initialization"].update(policy=ROUTE_RELOCALIZATION_POLICY,
|
||||
scope="selected-route", expected_attempts=108)
|
||||
for i in range(20, 200):
|
||||
boot.ingest(event(i * .5, i * 2 + 1), 0)
|
||||
boot.ingest(event(i * .5 + .001, i * 2 + 2, kind="points"), 0)
|
||||
boot.tick(int((i * .5 + 1.01) * 1e9), 0)
|
||||
assert boot.phase == "searching"
|
||||
return boot, result
|
||||
|
||||
|
||||
def test_long_complete_search_is_only_a_hypothesis_until_three_new_windows():
|
||||
boot, result = long_route_search()
|
||||
ready = 101_000_000_000
|
||||
decision = boot.offer_prior(result, ready, 0)
|
||||
assert decision["age_s"] > 90
|
||||
assert decision["provisional"] and decision["stationary_search_continuity"]
|
||||
assert not decision["accepted"] and boot.gate.matrix is None
|
||||
seen = set()
|
||||
for i, start in enumerate((101., 106., 111.)):
|
||||
sample, _ = fresh_window(boot, start, 500 + i * 20, segment=0)
|
||||
ids = {e["sequence"] for e in sample["events"]}
|
||||
assert not ids & seen
|
||||
seen |= ids
|
||||
assert all(e["monotonic_ns"] > ready for e in sample["events"])
|
||||
decision = boot.accept_fresh(fit(), sample, int((start + 4.2) * 1e9), 0)
|
||||
assert decision["accepted"]
|
||||
assert boot.phase == ("tracking" if i == 2 else "validating")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure", ["motion", "gap", "pose-only", "cloud-only", "silent", "identity"]
|
||||
)
|
||||
def test_long_search_cannot_survive_motion_or_invalid_live_receipts(failure):
|
||||
boot, result = long_route_search()
|
||||
now = 111_000_000_000
|
||||
if failure == "motion":
|
||||
boot.ingest(replace(event(100., 500), position=(.11, 0., 0.)), 0)
|
||||
elif failure == "gap":
|
||||
boot.ingest(event(100., 500), 1)
|
||||
elif failure == "identity":
|
||||
with pytest.raises(ValueError):
|
||||
boot.ingest(replace(event(100., 500), generation=2), 0)
|
||||
elif failure in {"pose-only", "cloud-only"}:
|
||||
boot.ingest(event(109., 500, kind="pose" if failure == "pose-only" else "points"), 0)
|
||||
boot.tick(now, boot.segment)
|
||||
decision = boot.offer_prior(result, now, boot.segment)
|
||||
assert boot.phase == "lost" and not decision["provisional"]
|
||||
assert boot.gate.matrix is None
|
||||
|
||||
|
||||
def test_long_search_can_try_next_place_only_with_another_fresh_window():
|
||||
boot, result = long_route_search()
|
||||
alternative = np.eye(4)
|
||||
alternative[0, 3] = 40
|
||||
result["initialization"]["candidate_queue"] = [
|
||||
dict(candidate_index=1, T_reference_query=np.eye(4).tolist(), ambiguous=False),
|
||||
dict(candidate_index=2, T_reference_query=alternative.tolist(), ambiguous=False),
|
||||
]
|
||||
assert boot.offer_prior(result, 101_000_000_000, 0)["provisional"]
|
||||
sample, _ = fresh_window(boot, 101., 500, segment=0)
|
||||
decision = boot.accept_fresh(dict(status="rejected"), sample, 105_200_000_000, 0)
|
||||
assert not decision["accepted"] and decision["next_candidate_trial"] == 2
|
||||
assert boot.gate.matrix is None and boot.floor_ns == 105_200_000_000
|
||||
assert np.allclose(boot.prior, alternative)
|
||||
assert boot.validation(106_000_000_000, 0) is None
|
||||
|
||||
|
||||
def fresh_window(boot, start, seq, segment=1):
|
||||
for i in range(7):
|
||||
boot.ingest(event(start + i * 0.5, seq + i * 2), segment)
|
||||
@@ -170,8 +241,9 @@ def test_initial_failure_and_later_loss_have_distinct_operator_messages():
|
||||
rejected = boot.offer_prior(result, 36_000_000_000, 0)
|
||||
assert rejected["reason"] == "initialization-incomplete"
|
||||
assert not boot.tracking_established
|
||||
assert "Синхронизация маршрута не завершилась" in phase_message(boot)
|
||||
assert "Остановите устройство и запись" in phase_message(boot)
|
||||
assert "Поиск не завершён" in phase_message(boot)
|
||||
assert "Переинициализировать" in phase_message(boot)
|
||||
assert "Остановите устройство" not in phase_message(boot)
|
||||
assert "потеряна" not in phase_message(boot)
|
||||
boot.tracking_established = True
|
||||
boot.stop("stale")
|
||||
@@ -266,7 +338,7 @@ def test_candidate_queue_never_bypasses_freshness_identity_or_ambiguity(failure)
|
||||
ready = 45_000_000_000 if failure == "expired" else 25_000_000_000
|
||||
if failure == "ambiguous":
|
||||
result["initialization"]["candidate_queue"][1]["ambiguous"] = True
|
||||
# Use the route policy's existing 35 s search fence for the late prefix case.
|
||||
# The legacy local protocol still has its 30 s search / 40 s source fences.
|
||||
if failure == "expired":
|
||||
boot.search_started_ns += 5_000_000_000
|
||||
assert boot.offer_prior(result, ready, 1)["provisional"]
|
||||
|
||||
Reference in New Issue
Block a user