diff --git a/dotbot/calibration/conics.py b/dotbot/calibration/conics.py index c8fbd2b5..48ca0f1d 100644 --- a/dotbot/calibration/conics.py +++ b/dotbot/calibration/conics.py @@ -690,6 +690,8 @@ def field_angle(centres: np.ndarray) -> float: # What a free-mode calibration records as its site's anchor: no mark in the # room is its origin. +# A site made by `site init` records the calibration it came from in its anchor +SELF_DEFINED_ANCHOR = "self-defined from spin calibration " FREE_FRAME_ANCHOR = ( "free mode: zero at the top-left of the rectangle around the spinning " "robots, grown by a robot's footprint; axes along that rectangle" @@ -822,7 +824,7 @@ def self_defined_site( extent, field_area, staging = starter_layout((field_w, field_h), size_mm) dx, dy = field_area.x, field_area.y shift = np.array([[1, 0, dx], [0, 1, dy], [0, 0, 1.0]]) - anchor = f"self-defined from spin calibration {calibration.id8}" + anchor = f"{SELF_DEFINED_ANCHOR}{calibration.id8}" stations = [] for st in calibration.stations: H = shift @ st.matrix @@ -853,5 +855,6 @@ def self_defined_site( created_at=calibration.created_at, tag=f"{calibration.tag}-{name}" if calibration.tag else "", robot=calibration.robot, + placed_from=calibration.id, ) return site, placed diff --git a/dotbot/calibration/lighthouse2.py b/dotbot/calibration/lighthouse2.py index 8114ccca..c76b8541 100644 --- a/dotbot/calibration/lighthouse2.py +++ b/dotbot/calibration/lighthouse2.py @@ -257,6 +257,8 @@ class Calibration: created_at: str = "" tag: str = "" robot: str = ROBOT_DEFAULT + # The id of the free-frame spin calibration this one was moved from + placed_from: str = "" path: Optional[Path] = None # The id the file on disk declares, when this came from one. A mismatch # with `id` means the file was hand-edited into a different calibration. @@ -686,6 +688,8 @@ def render_calibration(calibration: Calibration) -> str: ] if calibration.tag: out.append(f'tag = "{toml_escape(calibration.tag)}"') + if calibration.placed_from: + out.append(f'placed_from = "{calibration.placed_from}"') out += [ "", "[site]", @@ -867,6 +871,7 @@ def read_calibration_file(path: Path) -> Calibration: created_at=metadata.get("created_at", ""), tag=metadata.get("tag", ""), robot=metadata.get("robot", ROBOT_DEFAULT), + placed_from=str(metadata.get("placed_from", "")), path=path, ) calibration.stored_id = str(metadata.get("id", "")) @@ -1043,6 +1048,15 @@ def _file_metadata(path: Path) -> dict: return {} +def calibration_site_of(spec: str) -> Optional[str]: + """The site of the one calibration file under any site that `spec` + names, None when none or several do.""" + try: + return read_calibration_file(resolve_calibration_path(spec)).site.name + except (OSError, ValueError): + return None + + def load_calibration( spec: str, root: Optional[Path] = None, diff --git a/dotbot/calibration/multi_station.py b/dotbot/calibration/multi_station.py index 8d6e3acb..30054e31 100644 --- a/dotbot/calibration/multi_station.py +++ b/dotbot/calibration/multi_station.py @@ -1174,17 +1174,33 @@ def appendable_rounds(calibration: Calibration) -> int: calibration is in a frame a re-solve would not keep. """ # pylint: disable=import-outside-toplevel - from dotbot.calibration.conics import FREE_FRAME_ANCHOR + from dotbot.calibration.conics import FREE_FRAME_ANCHOR, SELF_DEFINED_ANCHOR + from dotbot.calibration.lighthouse2 import calibration_site_of if ( not calibration.tracks or calibration.site.anchor != FREE_FRAME_ANCHOR or not all(st.solved_from.startswith("conics") for st in calibration.stations) ): - raise ValueError( + refused = ( f"calibration {calibration.id8} is not a free-mode spin calibration, " - "so a round cannot be appended to it; append to the spin calibration " - "it was made from" + "so a round cannot be appended to it" + ) + anchor = calibration.site.anchor + if calibration.placed_from: + source = calibration.placed_from + how = f"it was placed from spin calibration {source[:8]}" + elif anchor.startswith(SELF_DEFINED_ANCHOR): + source = anchor[len(SELF_DEFINED_ANCHOR) :].strip() + how = f"its site was defined from spin calibration {source[:8]}" + else: + raise ValueError( + f"{refused}; append to the spin calibration it was made from" + ) + where = calibration_site_of(source) + raise ValueError( + f"{refused}: {how}, so append to that one" + + (f" (--append {source[:8]} --site {where})" if where else "") ) return max(t.round for t in calibration.tracks) + 1 diff --git a/dotbot/tests/test_calibration_spin.py b/dotbot/tests/test_calibration_spin.py index 04a64261..62b82775 100644 --- a/dotbot/tests/test_calibration_spin.py +++ b/dotbot/tests/test_calibration_spin.py @@ -18,6 +18,7 @@ from dotbot.calibration import conics, lighthouse2, push from dotbot.calibration import spin as spin_module from dotbot.calibration.lighthouse2 import counts_for_camera_point, load_calibration +from dotbot.calibration.multi_station import appendable_rounds from dotbot.calibration.spin import ( SPIN_CHUNK_RECORDS, SPIN_TAG, @@ -746,6 +747,46 @@ def test_append_refuses_a_corner_calibration(monkeypatch, lab, tmp_path): assert fleet.calls == [] +def test_append_to_a_placed_calibration_names_the_one_it_came_from(monkeypatch, lab): + _, fleet = _fleet() + result = _collect(monkeypatch, lab, fleet) + free_id = re.search(r"--from-calibration (\w+)", result.output).group(1) + result = CliRunner().invoke( + site_cmd.cmd, + ["init", "spun", "--from-calibration", free_id], + obj={"config": lab}, + ) + placed_id = re.search(r"push (\w+) --site spun", result.output).group(1) + _, fleet = _fleet() + result = _collect(monkeypatch, lab, fleet, "--site", "spun", "--append", placed_id) + assert result.exit_code != 0 + assert "not a free-mode spin calibration" in result.output + assert f"placed from spin calibration {free_id}" in result.output + assert f"--append {free_id} --site c405" in result.output + assert fleet.calls == [] + + +def test_a_placed_file_without_its_source_falls_back_to_the_site_anchor( + monkeypatch, lab +): + _, fleet = _fleet() + result = _collect(monkeypatch, lab, fleet) + free_id = re.search(r"--from-calibration (\w+)", result.output).group(1) + result = CliRunner().invoke( + site_cmd.cmd, + ["init", "spun", "--from-calibration", free_id], + obj={"config": lab}, + ) + path = Path(re.search(r"saved to (\S+)", result.output).group(1)) + text = path.read_text() + assert f'placed_from = "{free_id}' in text + path.write_text(re.sub(r'placed_from = "\w+"\n', "", text)) + with pytest.raises(ValueError) as refused: + appendable_rounds(lighthouse2.read_calibration_file(path)) + assert f"its site was defined from spin calibration {free_id}" in str(refused.value) + assert f"--append {free_id} --site c405" in str(refused.value) + + def test_append_and_drop_station_go_with_spin(lab): for flags in (["--append", "x"], ["--drop-station", "2"]): result = CliRunner().invoke(