367 lines
14 KiB
Python
367 lines
14 KiB
Python
"""Selected-route retrieval is distinct from the old start-neighbourhood search."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from k1link.missions.route_relocalization import (
|
|
ROUTE_RELOCALIZATION_POLICY,
|
|
ReferenceGrid,
|
|
choose_route_location,
|
|
local_submap,
|
|
rank_route_candidates,
|
|
relocalize_route,
|
|
relocalize_start_then_route,
|
|
)
|
|
from k1link.missions.stationary_bootstrap import StationaryBootstrap
|
|
|
|
|
|
def scene():
|
|
"""Three deliberately different local places along one 90-m route."""
|
|
rng = np.random.default_rng(92)
|
|
parts = []
|
|
for center, scale in (
|
|
(10.0, (2.0, 0.7, 1.0)),
|
|
(45.0, (0.8, 4.0, 2.0)),
|
|
(80.0, (4.0, 1.2, 0.4)),
|
|
):
|
|
points = rng.normal(size=(900, 3)) * scale
|
|
points[:, 0] += center
|
|
parts.append(points)
|
|
return np.concatenate(parts), np.array([[0.0, 0.0, 0.0], [90.0, 0.0, 0.0]])
|
|
|
|
|
|
def candidate_result(matrix, *, overlap=0.95, rmse=0.1):
|
|
return dict(
|
|
status="candidate",
|
|
T_reference_query=np.asarray(matrix).tolist(),
|
|
initial_T_reference_query=np.asarray(matrix).tolist(),
|
|
matched_query_indices=[1],
|
|
overlap=overlap,
|
|
inlier_rmse_m=rmse,
|
|
registration_seconds=0.01,
|
|
localization_confirmed=False,
|
|
vehicle_control=False,
|
|
)
|
|
|
|
|
|
def attempt(index, progress, matrix, *, overlap=0.95, rmse=0.1):
|
|
return dict(
|
|
candidate=dict(
|
|
index=index,
|
|
position=[progress, 0, 0],
|
|
progress_m=float(progress),
|
|
descriptor_distance=0.1,
|
|
),
|
|
yaw_deg=0.0,
|
|
result=candidate_result(matrix, overlap=overlap, rmse=rmse),
|
|
)
|
|
|
|
|
|
def test_descriptor_retrieval_covers_full_selected_route_not_its_start():
|
|
reference, path = scene()
|
|
# The stationary K1 cloud is at the middle place in another local SLAM frame.
|
|
query = reference[(reference[:, 0] > 37) & (reference[:, 0] < 53)] - [45.0, 3.0, 0.0]
|
|
ranked, coverage = rank_route_candidates(reference, path, query)
|
|
|
|
assert coverage["descriptor_scope"] == "entire-selected-route"
|
|
assert coverage["route_anchor_count"] > 10
|
|
assert ranked
|
|
assert len(ranked) == coverage["descriptor_covered_anchor_count"]
|
|
assert len(ranked) > ROUTE_RELOCALIZATION_POLICY["candidate_batch_size"]
|
|
assert abs(ranked[0].progress_m - 45.0) <= ROUTE_RELOCALIZATION_POLICY["anchor_spacing_m"]
|
|
|
|
|
|
def test_full_route_atlas_is_not_rejected_by_one_gicp_target_cap():
|
|
reference, _ = scene()
|
|
full_route = np.tile(reference, (40, 1)) # 108,000 map points, not one GICP target.
|
|
atlas = ReferenceGrid(full_route)
|
|
target = local_submap(atlas, [45.0, 0.0, 0.0], 28.0, maximum_points=100_000)
|
|
|
|
assert len(full_route) > 100_000
|
|
assert 300 <= len(target) <= 100_000
|
|
|
|
|
|
def test_dense_eighty_metre_submap_keeps_its_footprint_under_the_gicp_budget():
|
|
x, y = np.meshgrid(np.arange(-40.0, 40.0, 0.2), np.arange(-40.0, 40.0, 0.2))
|
|
reference = np.column_stack((x.ravel(), y.ravel(), np.zeros(x.size)))
|
|
target = local_submap(reference, [0.0, 0.0, 0.0], 80.0, maximum_points=1_000)
|
|
|
|
assert 300 <= len(target) <= 1_000
|
|
|
|
|
|
def test_middle_of_route_relocalisation_qualifies_with_real_gicp():
|
|
pytest.importorskip("small_gicp")
|
|
reference, path = scene()
|
|
query = reference[(reference[:, 0] > 37) & (reference[:, 0] < 53)] - [45.0, 3.0, 0.0]
|
|
output = relocalize_route(reference, path, query, [0.0, 0.0, 0.0])
|
|
|
|
assert output["status"] == "candidate", output["reasons"]
|
|
assert output["initialization"]["complete"]
|
|
assert abs(output["initialization"]["selected_route_progress_m"] - 45.0) <= 5.0
|
|
assert output["overlap"] > 0.9 and output["inlier_rmse_m"] < 0.05
|
|
assert not output["localization_confirmed"] and not output["vehicle_control"]
|
|
|
|
|
|
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()
|
|
query = reference[:700]
|
|
local = candidate_result(np.eye(4))
|
|
local["initialization"] = dict(complete=True, reason=None, attempts=[{}] * 108)
|
|
monkeypatch.setattr(module, "acquire_entry", lambda *args, **kwargs: dict(local))
|
|
|
|
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", route)
|
|
output = relocalize_start_then_route(reference, path, query, [0.0, 0.0, 0.0])
|
|
|
|
assert output["status"] == "candidate"
|
|
assert calls
|
|
assert output["initialization"]["strategy"] == ROUTE_RELOCALIZATION_POLICY["strategy"]
|
|
assert output["initialization"]["expected_attempts"] == 109
|
|
assert output["initialization"]["stages"][0]["name"] == "dense-start"
|
|
|
|
|
|
def test_hybrid_records_a_route_recovery_only_after_a_dense_start_rejection(monkeypatch):
|
|
import k1link.missions.route_relocalization as module
|
|
|
|
reference, path = scene()
|
|
query = reference[:700]
|
|
local = candidate_result(np.eye(4))
|
|
local.update(status="rejected", reasons=["no-admissible-entry"])
|
|
local["initialization"] = dict(complete=True, reason="no-admissible-entry", attempts=[{}])
|
|
recovered = candidate_result(np.eye(4))
|
|
recovered["initialization"] = dict(
|
|
complete=True,
|
|
reason=None,
|
|
attempts=[{}],
|
|
selected_candidate_index=4,
|
|
selected_route_progress_m=20.0,
|
|
)
|
|
monkeypatch.setattr(module, "acquire_entry", lambda *args, **kwargs: dict(local))
|
|
monkeypatch.setattr(module, "relocalize_route", lambda *args, **kwargs: dict(recovered))
|
|
|
|
output = relocalize_start_then_route(reference, path, query, [0.0, 0.0, 0.0])
|
|
|
|
assert output["status"] == "candidate"
|
|
assert [stage["name"] for stage in output["initialization"]["stages"]] == [
|
|
"dense-start",
|
|
"route-recovery",
|
|
]
|
|
assert output["initialization"]["expected_attempts"] == 2
|
|
|
|
|
|
def test_equally_good_distinct_route_places_are_rejected_as_ambiguous():
|
|
first, second = np.eye(4), np.eye(4)
|
|
second[0, 3] = 50.0
|
|
output = choose_route_location(
|
|
[attempt(0, 0.0, first), attempt(10, 50.0, second, overlap=0.94, rmse=0.11)],
|
|
[0.0, 0.0, 0.0],
|
|
complete=True,
|
|
)
|
|
|
|
assert output["status"] == "rejected"
|
|
assert output["reasons"] == ["ambiguous-route-location"]
|
|
assert output["matched_query_indices"] == []
|
|
assert not output["localization_confirmed"] and not output["vehicle_control"]
|
|
|
|
|
|
def test_no_qualified_candidate_is_a_route_location_failure_not_a_fake_prior():
|
|
output = choose_route_location([], [0.0, 0.0, 0.0], complete=True)
|
|
|
|
assert output["status"] == "rejected"
|
|
assert output["reasons"] == ["no-route-location"]
|
|
assert output["initialization"]["complete"]
|
|
assert output["initialization"]["expected_attempts"] == 0
|
|
|
|
|
|
def test_global_result_must_account_for_every_generated_hypothesis_before_prior():
|
|
boot = StationaryBootstrap(
|
|
np.array([[0.0, 0, 0], [40.0, 0, 0]]),
|
|
initialization_policy=ROUTE_RELOCALIZATION_POLICY,
|
|
)
|
|
# Exercise the policy contract directly; no old 108-seed local-start
|
|
# assumption is permitted for whole-route retrieval.
|
|
initialization = dict(
|
|
policy=ROUTE_RELOCALIZATION_POLICY,
|
|
scope="selected-route",
|
|
complete=True,
|
|
expected_attempts=2,
|
|
attempts=[{}, {}],
|
|
)
|
|
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",
|
|
T_reference_query=np.eye(4).tolist(),
|
|
initialization=initialization,
|
|
),
|
|
1_000_000_000,
|
|
0,
|
|
)
|
|
assert accepted["provisional"]
|
|
|
|
second = StationaryBootstrap(
|
|
np.array([[0.0, 0, 0], [40.0, 0, 0]]),
|
|
initialization_policy=ROUTE_RELOCALIZATION_POLICY,
|
|
)
|
|
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(
|
|
status="candidate",
|
|
T_reference_query=np.eye(4).tolist(),
|
|
initialization=initialization,
|
|
),
|
|
1_000_000_000,
|
|
0,
|
|
)
|
|
assert not rejected["provisional"] and rejected["reason"] == "initialization-incomplete"
|
|
|
|
|
|
def test_global_ambiguity_has_a_specific_operator_message():
|
|
from k1link.missions.stationary_live import phase_message
|
|
|
|
boot = StationaryBootstrap(
|
|
np.array([[0.0, 0, 0], [40.0, 0, 0]]),
|
|
initialization_policy=ROUTE_RELOCALIZATION_POLICY,
|
|
)
|
|
boot.phase, boot.reason = "lost", "initialization-ambiguous"
|
|
assert "несколько похожих участков" in phase_message(boot)
|
|
|
|
|
|
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"
|
|
)
|
|
|
|
|
|
def test_precise_search_reaches_the_last_ranked_anchor(monkeypatch):
|
|
import k1link.missions.route_relocalization as module
|
|
|
|
reference, path = scene()
|
|
query = reference[:700]
|
|
ranked, _ = rank_route_candidates(reference, path, query)
|
|
calls = []
|
|
prepared_targets = []
|
|
original_prepare = module.PreparedReference.__init__
|
|
|
|
def prepare(self, target):
|
|
prepared_targets.append(target)
|
|
original_prepare(self, target)
|
|
|
|
def register(self, query, initial, *, policy):
|
|
calls.append(policy)
|
|
result = candidate_result(np.eye(4))
|
|
if len(calls) < (len(ranked) - 1) * 6 + 1:
|
|
result.update(status="rejected", reasons=["fixture mismatch"])
|
|
return result
|
|
|
|
monkeypatch.setattr(module.PreparedReference, "register", register)
|
|
monkeypatch.setattr(module.PreparedReference, "__init__", prepare)
|
|
result = relocalize_route(reference, path, query, [0, 0, 0])
|
|
info = result["initialization"]
|
|
assert result["status"] == "candidate"
|
|
assert info["selected_candidate_index"] == ranked[-1].index
|
|
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_elapsed_time_does_not_discard_the_rest_of_the_finite_queue(monkeypatch):
|
|
import k1link.missions.route_relocalization as module
|
|
|
|
reference, path = scene()
|
|
elapsed = [0.0]
|
|
|
|
def register(self, query, initial, *, policy):
|
|
elapsed[0] += 12
|
|
return candidate_result(np.eye(4))
|
|
|
|
monkeypatch.setattr(module.PreparedReference, "register", register)
|
|
result = relocalize_route(reference, path, reference[:700], [0, 0, 0],
|
|
clock=lambda: elapsed[0])
|
|
info = result["initialization"]
|
|
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():
|
|
first, second = np.eye(4), np.eye(4)
|
|
second[0, 3] = 40
|
|
result = choose_route_location([attempt(0, 0, first), attempt(0, 0, second)],
|
|
[0, 0, 0], complete=True)
|
|
assert result["reasons"] == ["ambiguous-route-location"]
|
|
|
|
|
|
def test_fresh_confirmation_fallback_skips_dense_search(monkeypatch):
|
|
import k1link.missions.route_relocalization as module
|
|
|
|
reference, path = scene()
|
|
def no_dense(*args, **kwargs):
|
|
raise AssertionError("Already disproved dense-start prior must not repeat.")
|
|
monkeypatch.setattr(module, "acquire_entry", no_dense)
|
|
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"
|