feat: finalize corrected-route planning and Rerun recording review

This commit is contained in:
DCCONSTRUCTIONS
2026-09-22 10:10:03 +03:00
parent c804d89b18
commit 2e5d52521f
132 changed files with 14141 additions and 898 deletions
+82 -4
View File
@@ -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