diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index c6ae8501..cecdc654 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -19,6 +19,32 @@ XYPosList: TypeAlias = list[XYPos] XYZPosList: TypeAlias = list[XYZPos] +def enforce_xy_tuple(value: XYPos) -> XYPos: + """ + Used for enfocring that an input is a tuple and is the correct length + """ + if not isinstance(value, (list, tuple)): + raise ValueError("2 value tuple expected") + if not len(value) == 2: + raise ValueError("2 value tuple expected") + if isinstance(value, list): + return tuple(value) + return value + + +def enforce_xyz_tuple(value: XYZPos) -> XYZPos: + """ + Used for enfocring that an input is a tuple and is the correct length + """ + if not isinstance(value, (list, tuple)): + raise ValueError("3 value tuple expected") + if not len(value) == 3: + raise ValueError("3 value tuple expected") + if isinstance(value, list): + return tuple(value) + return value + + class ScanPlanner: """ A base class for a scan planner. @@ -29,7 +55,7 @@ class ScanPlanner: """ def __init__(self, intial_position: XYPos, planner_settings: Optional[dict] = None): - self._initial_position = tuple(intial_position) + self._initial_position = enforce_xy_tuple(intial_position) self._parse(planner_settings) @@ -102,13 +128,15 @@ class ScanPlanner: next_location = self._remaining_locations[0] # If focussed locations exist return closest location, favouring most recent - if self._focused_locations: - z = self.closest_focus_site(next_location)[2] - else: + closest_pos = self.closest_focus_site(next_location) + if closest_pos is None: z = None + else: + z = closest_pos[2] + return next_location, z - def closest_focus_site(self, xy_pos: XYPos) -> XYZPos: + def closest_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]: """ Return the xyz position of the closest site where focus was achieved to the input xy_position, with the most recently taken image returned in @@ -135,7 +163,7 @@ class ScanPlanner: return self._focused_locations[indicies[-1]] def mark_location_visited( - self, xyz_pos: XYPos, imaged: bool, focused: bool + self, xyz_pos: XYZPos, imaged: bool, focused: bool ) -> None: """ Mark the location as visited @@ -146,7 +174,7 @@ class ScanPlanner: focused: true if autofocus completed successfully """ # ensure is tuple! - xyz_pos = tuple(xyz_pos) + xyz_pos = enforce_xyz_tuple(xyz_pos) xy_pos = xyz_pos[:2] # Remove the expected position from the remaining locations list @@ -196,7 +224,7 @@ class SmartSpiral(ScanPlanner): if not planner_settings: raise ValueError(invalid_msg + ",".join(expected_keys)) if not all(keys in planner_settings for keys in expected_keys): - raise ValueError(invalid_msg + ",".join(expected_keys)) + raise KeyError(invalid_msg + ",".join(expected_keys)) self._dx = int(planner_settings["dx"]) self._dy = int(planner_settings["dy"]) @@ -211,7 +239,7 @@ class SmartSpiral(ScanPlanner): return [self._initial_position] def mark_location_visited( - self, xyz_pos: XYPos, imaged: bool = True, focused: bool = True + self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True ) -> None: """ Mark the location as visited. Adjust extra poisitons accordingly @@ -224,7 +252,7 @@ class SmartSpiral(ScanPlanner): # First call the base class to update the positions super().mark_location_visited(xyz_pos, imaged, focused) - xy_pos = tuple(xyz_pos[:2]) + xy_pos = enforce_xy_tuple(xyz_pos[:2]) if imaged: self._add_surrounding_positions(xy_pos) self._re_sort_remaining_locations(xy_pos) @@ -287,7 +315,9 @@ class SmartSpiral(ScanPlanner): return np.max(np.abs(displacement_in_moves)) -def distance_between(current_pos: XYPos, next_pos: XYPos) -> float: +def distance_between( + current_pos: XYPos | np.ndarray, next_pos: XYPos | np.ndarray +) -> float: """ Calculate the distance between the two xy positions @@ -295,4 +325,4 @@ def distance_between(current_pos: XYPos, next_pos: XYPos) -> float: """ next_pos = np.array(next_pos, dtype="float64") current_pos = np.array(current_pos, dtype="float64") - return np.linalg.norm(next_pos - current_pos) + return float(np.linalg.norm(next_pos - current_pos)) diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py new file mode 100644 index 00000000..373415d0 --- /dev/null +++ b/tests/test_scan_planners.py @@ -0,0 +1,116 @@ +import pytest +from openflexure_microscope_server import scan_planners +from copy import copy + + +def test_enforce_xy_tuple(): + bad_len_vals = [[1], [], (1,), (2, 4, 4), [1, 4, 5, 7]] + bad_type_vals = ["hi", 1, {"this": "that"}, {1, 2}] + for value in bad_len_vals + bad_type_vals: + with pytest.raises(ValueError): + scan_planners.enforce_xy_tuple(value) + + assert (1, 6) == scan_planners.enforce_xy_tuple((1, 6)) + assert (1, 6) == scan_planners.enforce_xy_tuple([1, 6]) + + +def test_enforce_xyz_tuple(): + bad_len_vals = [[1], [], (1,), (2, 4), [1, 4, 5, 7]] + bad_type_vals = ["hi!", 1, {"this": "that"}, {1, 2, 3}] + for value in bad_len_vals + bad_type_vals: + with pytest.raises(ValueError): + scan_planners.enforce_xyz_tuple(value) + + assert (1, 6, 2) == scan_planners.enforce_xyz_tuple((1, 6, 2)) + assert (1, 6, 6) == scan_planners.enforce_xyz_tuple([1, 6, 6]) + + +def test_v_basic_smart_spiral(): + intial_position = (100, 50) + planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000} + planner = scan_planners.SmartSpiral( + intial_position=intial_position, planner_settings=planner_settings + ) + # Create a planner it shouldn't start complete + assert not planner.scan_complete + # When we start it should want to stay in the inital pos and have + # no z_estimate + xy_pos, z_pos = planner.get_next_location_and_z_estimate() + assert xy_pos == intial_position + assert z_pos is None + + # Try to make imaged with only xy_position + with pytest.raises(ValueError): + planner.mark_location_visited(xy_pos, imaged=False, focused=False) + # scan still not complete + assert not planner.scan_complete + # if we mark this position as visited but not imaged + planner.mark_location_visited( + (xy_pos[0], xy_pos[1], 10), imaged=False, focused=False + ) + # scan is now complete + assert planner.scan_complete + + +def test_bad_smart_spiral_settings(): + intial_position = (100, 50) + planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000} + with pytest.raises(KeyError): + keys = ["dx", "dy", "max_dist"] + for delkey in keys: + bad_planner_settings = copy(planner_settings) + del bad_planner_settings[delkey] + scan_planners.SmartSpiral( + intial_position=intial_position, planner_settings=bad_planner_settings + ) + with pytest.raises(ValueError): + keys = ["dx", "dy", "max_dist"] + for badkey in keys: + bad_planner_settings = copy(planner_settings) + bad_planner_settings[badkey] = "I can't be converted to an int" + scan_planners.SmartSpiral( + intial_position=intial_position, planner_settings=bad_planner_settings + ) + + +def test__smart_spiral_next_pos(): + intial_position = (100, 50) + planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000} + planner = scan_planners.SmartSpiral( + intial_position=intial_position, planner_settings=planner_settings + ) + # Create a planner it shouldn't start complete + assert not planner.scan_complete + # When we start it should want to stay in the inital pos and have + # no z_estimate + xy_pos, z_pos = planner.get_next_location_and_z_estimate() + assert xy_pos == intial_position + assert z_pos is None + z_focus = 10 + xyz_pos = (xy_pos[0], xy_pos[1], z_focus) + # if we mark this position as visited and imaged + planner.mark_location_visited(xyz_pos, imaged=True, focused=True) + + # scan is not complete + assert not planner.scan_complete + + # Lists should have updated to add the position to histories + assert xyz_pos in planner._imaged_locations + assert xyz_pos in planner._focused_locations + assert xy_pos in planner._path_history + + # remove from planned + assert xy_pos not in planner._remaining_locations + + # Add 4 new points to planned + assert (100, 0) in planner._remaining_locations + assert (100, 100) in planner._remaining_locations + assert (150, 50) in planner._remaining_locations + assert (50, 50) in planner._remaining_locations + assert len(planner._remaining_locations) == 4 + + # Now the next location is update + xy_pos, z_pos = planner.get_next_location_and_z_estimate() + # move in negative x-dir first + assert xy_pos == (50, 50) + assert z_pos is z_focus