157 lines
8.2 KiB
Python
157 lines
8.2 KiB
Python
"""One partition contract at scheduling, support and journal boundaries."""
|
|
|
|
from dataclasses import replace
|
|
|
|
import pytest
|
|
|
|
from linkerhand_calibration.core.domain.capture_plan import (
|
|
ADAPTIVE, CapturePlan, configure_training, evidence_digest, select_task_training)
|
|
from linkerhand_calibration.core.domain.sampling import command_nodes
|
|
from linkerhand_calibration.profiles import load_bundled_hand_profile
|
|
from linkerhand_calibration.runtime.engine import CalibrationEngine
|
|
from linkerhand_calibration.runtime.execution import SessionExecution
|
|
from linkerhand_calibration.runtime.session import CalibrationPhase
|
|
from linkerhand_calibration.runtime.task_quality import evaluate_task_input_support
|
|
from linkerhand_calibration.runtime.training import resolve_capture_plan
|
|
|
|
|
|
@pytest.fixture
|
|
def profile():
|
|
return configure_training(load_bundled_hand_profile("o30_right_18"), ADAPTIVE)
|
|
|
|
|
|
def decision(profile, task, cycles, action):
|
|
from linkerhand_calibration.core.fitting.training_quality import TRAINING_DECISION_VERSION
|
|
row = dict(kind="training_decision", version=TRAINING_DECISION_VERSION,
|
|
task_name=task.key, assessed_cycles=list(cycles), decision=action,
|
|
input_plan_sha256=CapturePlan.from_profile(profile).sha256, failures=["noisy"] if action == "fail" else [])
|
|
return {**row, "decision_sha256": evidence_digest(row)}
|
|
|
|
|
|
def header(profile):
|
|
return dict(kind="session_start", capture_plan=CapturePlan.from_profile(profile).as_dict())
|
|
|
|
|
|
def support_rows(profile, task, cycles):
|
|
return [dict(kind="joint_sample", task_name=task.key, joint=task.joints[0],
|
|
view=profile.measurement.measurements[task.joints[0]].view, sample_phase="steady",
|
|
cycle=cycle, direction=direction, command_u8=value, image_stamp_ns=index * 10 + node + 1)
|
|
for index, (cycle, direction) in enumerate((c, d) for c in cycles for d in ("increasing", "decreasing"))
|
|
for node, value in enumerate((0., 127., 255.))]
|
|
|
|
|
|
def test_optional_round_keeps_holdout_identity_grid_and_motion_segments(profile):
|
|
units = CalibrationEngine(profile).scan_units()
|
|
for task in profile.motion.tasks:
|
|
original_grid = command_nodes(profile, task, holdout=True)
|
|
profile = select_task_training(profile, task.key, (0, 1))
|
|
assert command_nodes(profile, task, holdout=True) == original_grid
|
|
shortened = CalibrationEngine(profile).scan_units()
|
|
assert len(units) == 144 and len(shortened) == 108
|
|
assert shortened == tuple(unit for unit in units if unit.cycle != 2)
|
|
assert all(task.holdout == 3 for task in CapturePlan.from_profile(profile).tasks)
|
|
|
|
|
|
def test_secondary_camera_uses_its_primary_joint_partition():
|
|
from linkerhand_calibration.core.domain.capture_plan import task_for_joint, training_cycles
|
|
profile = load_bundled_hand_profile("g20_right_19")
|
|
for primary, secondary in profile.measurement.cross_view_sources.items():
|
|
assert task_for_joint(profile, secondary) == task_for_joint(profile, primary)
|
|
assert training_cycles(profile, joint=secondary) == (0, 1, 2)
|
|
|
|
|
|
@pytest.mark.parametrize("mode,cycles", [("interleaved", (0, 1, 2, 3)), ("separate", (4, 5))])
|
|
def test_command_support_uses_the_correct_phase_partition(profile, mode, cycles):
|
|
profile = replace(profile, acquisition=replace(profile.acquisition, command_capture_mode=mode))
|
|
task = profile.motion.tasks[0]
|
|
rows = support_rows(profile, task, cycles)
|
|
assert evaluate_task_input_support(profile, task, rows).passed
|
|
# Validation endpoints cannot extend training support.
|
|
reduced = [row for row in rows if row["cycle"] == cycles[-1] or row["command_u8"] < 255.]
|
|
assert not evaluate_task_input_support(profile, task, reduced).passed
|
|
|
|
|
|
@pytest.mark.parametrize("action,expected", [("freeze", 3), ("add_training", 2), ("fail", 2)])
|
|
def test_session_advances_only_after_bounded_training_decision(profile, monkeypatch, action, expected):
|
|
from linkerhand_calibration.runtime.engine import SweepQuality
|
|
driver = SessionExecution(profile)
|
|
task = profile.motion.tasks[0]
|
|
round_cycle = 2 if action == "fail" else 1
|
|
index = max(i for i, u in enumerate(driver.session._units) if u.task_key == task.key and u.cycle == round_cycle)
|
|
driver.session._unit_index = index
|
|
driver.session.phase = CalibrationPhase.EVALUATE
|
|
driver.completed_units.update(u.identity for u in driver.session._units[:index])
|
|
monkeypatch.setattr("linkerhand_calibration.runtime.execution.evaluate_capture_unit",
|
|
lambda *a, **k: SweepQuality(True, (), (), {}))
|
|
driver.evaluate([], training_decision=decision(profile, task, range(round_cycle + 1), action))
|
|
if action == "fail":
|
|
assert driver.session.phase == CalibrationPhase.FAILED
|
|
else:
|
|
assert driver.session.current_unit.cycle == expected
|
|
assert driver.session.status().task.cycle_count == (3 if action == "freeze" else 4)
|
|
if action == "freeze":
|
|
assert driver.session.status().task.cycle == 3
|
|
|
|
|
|
def test_journal_rejects_future_holdout_and_changed_decision(profile):
|
|
task = profile.motion.tasks[0]
|
|
row = decision(profile, task, (0, 1), "freeze")
|
|
current = resolve_capture_plan(profile, [header(profile), row])
|
|
assert current.quality.task_training_cycles[task.key] == (0, 1)
|
|
with pytest.raises(ValueError, match="holdout_before_training_freeze"):
|
|
resolve_capture_plan(profile, [header(profile), support_rows(profile, task, (3,))[0], row])
|
|
with pytest.raises(ValueError, match="training_decision_invalid"):
|
|
resolve_capture_plan(profile, [header(profile), {**row, "assessed_cycles": [0, 1, 2]}])
|
|
with pytest.raises(ValueError, match="training_observation_after_freeze"):
|
|
resolve_capture_plan(profile, [header(profile), row, support_rows(profile, task, (0,))[0]])
|
|
with pytest.raises(ValueError, match="matching_plan_header"):
|
|
resolve_capture_plan(profile, [])
|
|
with pytest.raises(ValueError, match="third_training_without_decision"):
|
|
resolve_capture_plan(profile, [header(profile), support_rows(profile, task, (2,))[0]])
|
|
|
|
|
|
def test_fixed_and_adaptive_checkpoints_are_isolated(profile):
|
|
fixed = configure_training(profile, "fixed")
|
|
engine = CalibrationEngine(profile)
|
|
from linkerhand_calibration.core.geometry.pnp import POSE_TRACKING_POLICY_VERSION
|
|
from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_VERSION
|
|
row = {**header(profile), "profile_id": profile.key.profile_id,
|
|
"acquisition_policy_version": ACQUISITION_POLICY_VERSION,
|
|
"pose_tracking_policy_version": POSE_TRACKING_POLICY_VERSION,
|
|
"capture_schedule_version": engine.capture_schedule_version,
|
|
"calibration_scope": None}
|
|
from linkerhand_calibration.core.urdf.partial_scope import profile_scope
|
|
row["calibration_scope"] = profile_scope(profile)
|
|
assert engine.resume_compatible(row)
|
|
assert not CalibrationEngine(fixed).resume_compatible(row)
|
|
row["capture_schedule_version"] = CalibrationEngine(fixed).capture_schedule_version
|
|
assert not engine.resume_compatible(row)
|
|
|
|
|
|
def test_stage_timing_accounts_for_elapsed_time_once():
|
|
from linkerhand_calibration.runtime.timing_diagnostics import StageTiming
|
|
timing = StageTiming(0.)
|
|
timing.mark_start(1.)
|
|
timing.enter("geometry_solving", 2.)
|
|
timing.enter("geometry_solving", 5.)
|
|
timing.enter("steady_sampling", 6.)
|
|
report = timing.as_dict(7.)
|
|
assert report["stage_seconds"] == {"startup": 2., "geometry_solving": 4., "steady_sampling": 1.}
|
|
assert sum(report["stage_seconds"].values()) == report["elapsed_seconds"]
|
|
timing.enter('terminal', 9.)
|
|
assert timing.as_dict(20.)['start_to_terminal_seconds'] == 8.
|
|
|
|
|
|
def test_adaptive_resume_restarts_suffix_after_incomplete_dependency(profile):
|
|
from linkerhand_calibration.runtime.joint_resume import JointResume
|
|
tasks = profile.motion.tasks[:3]
|
|
for task in tasks:
|
|
profile = select_task_training(profile, task.key, (0, 1))
|
|
engine = CalibrationEngine(profile)
|
|
resume = JointResume(profile)
|
|
resume.units = {u.identity for u in engine.scan_units() if u.task_key in {t.key for t in tasks}}
|
|
resume.units.remove(next(u.identity for u in engine.scan_units() if u.task_key == tasks[1].key))
|
|
resume.restrict_adaptive_prefix(engine)
|
|
assert {key[0] for key in resume.units} == {tasks[0].key}
|
|
assert resume.profile.quality.task_training_cycles == {tasks[0].key: (0, 1)}
|