diff --git a/src/openflexure_microscope_server/scan_planners.py b/src/openflexure_microscope_server/scan_planners.py index 35f5f00b..38a8d317 100644 --- a/src/openflexure_microscope_server/scan_planners.py +++ b/src/openflexure_microscope_server/scan_planners.py @@ -240,129 +240,6 @@ class ScanPlanner: class SmartSpiral(ScanPlanner): - """ - This is a smart spiral scan that spirals out from the centre. - - Each time and image is taken the four neighbouring images are added - to the list of poisitions to image (unless they are already listed or - tried). However if a location is not imaged due no sample being detected - then neibouring positions are not imaged. - - The next image taken is the closes to the centre (considering the largest - of vertical or horizontal distance), ties are broken by the distance from - the current position. - """ - - _max_dist: int = 0 - _dx: int = 0 - _dy: int = 0 - - def _parse(self, planner_settings: Optional[dict] = None) -> None: - """ - Parse SmartSpiral Settings. This should be a dictionary - - "dx" - the movement size in x - "dy" - the movement size in y - "max_dist" - The maximum distance to a location can be from the centre. - """ - - expected_keys = ["max_dist", "dx", "dy"] - invalid_msg = "SmartSpiral requires a planner_settings dictionary with keys: " - if not planner_settings: - raise ValueError(invalid_msg + ",".join(expected_keys)) - if not all(keys in planner_settings for keys in expected_keys): - raise KeyError(invalid_msg + ",".join(expected_keys)) - - self._dx = int(planner_settings["dx"]) - self._dy = int(planner_settings["dy"]) - self._max_dist = int(planner_settings["max_dist"]) - - def _intial_location_list(self) -> XYPosList: - """ - Called on initalisation. Sets the initial list of locations for this scan planner - - For smart spiral this is just the first point - """ - return [self._initial_position] - - def mark_location_visited( - self, xyz_pos: XYZPos, imaged: bool = True, focused: bool = True - ) -> None: - """ - Mark the location as visited. Adjust extra positions accordingly - - Args: - xyz_pos: the x_y_z position - imaged: true if an image was taken, false if not (due to background detect) - focused: true if autofocus completed successfully - """ - # First call the base class to update the positions - super().mark_location_visited(xyz_pos, imaged, focused) - - xy_pos = enforce_xy_tuple(xyz_pos[:2]) - if imaged: - self._add_surrounding_positions(xy_pos) - self._re_sort_remaining_locations(xy_pos) - - def _add_surrounding_positions(self, xy_pos: XYPos) -> None: - """ - This adds the surrounding (4 point connectivity) positions - to the remaining locations if they are not too far away or - already planned or already visited - """ - new_positions = [ - (xy_pos[0] - self._dx, xy_pos[1]), - (xy_pos[0] + self._dx, xy_pos[1]), - (xy_pos[0], xy_pos[1] - self._dy), - (xy_pos[0], xy_pos[1] + self._dy), - ] - - for new_pos in new_positions: - # Skip position if already planned or visited - if self.position_planned(new_pos) or self.position_visited(new_pos): - continue - - dist = distance_between(new_pos, self._initial_position) - if dist > self._max_dist: - LOGGER.debug("Rejected moving to %s as it is out of range", new_pos) - continue - self._remaining_locations.append(new_pos) - - def _re_sort_remaining_locations(self, current_pos: XYPos) -> None: - """ - Sort the remaining positions besed on the current location - """ - - # Defined rather than use a lambda for readability - def sort_key(pos): - return self.moves_from_centre(pos), distance_between(current_pos, pos) - - self._remaining_locations.sort(key=sort_key) - - def moves_from_centre( - self, - xy_pos: XYPos, - ) -> float: - """ - Return the number of moves from the centre in the x or y direction - whichever is largest - - Args: - xy_pos: the position - - Note this has been renamed from `steps_from_centre` as that implied - stepper motor steps not number of moves in a scan - """ - move_size = np.array([self._dx, self._dy]) - starting_pos = np.array(self._initial_position, dtype="float64") - current_pos = np.array(xy_pos, dtype="float64") - - displacement_in_moves = (current_pos - starting_pos) / move_size - - return np.max(np.abs(displacement_in_moves)) - - -class ShortSmartSpiral(ScanPlanner): """ This is a smart spiral scan that spirals out from the centre, but prioritises short moves over rigidly sticking to minimising radius from the centre of the diff --git a/tests/test_scan_planners.py b/tests/test_scan_planners.py index da5ff961..93750764 100644 --- a/tests/test_scan_planners.py +++ b/tests/test_scan_planners.py @@ -253,7 +253,22 @@ def test_closest_focus_wth_large_numbers(): def test_example_smart_spiral(): - _, planner = scan_test_helpers.example_smart_spiral() - expected_planner = scan_test_helpers.get_expected_result_for_example_smart_spiral() - assert planner.path_history == expected_planner.path_history - assert planner.imaged_locations == expected_planner.imaged_locations + """Test the smart spiral scan algorithm on the sample types listed + below and defined in scan_test_helpers.load_sample_points + + Will fail if the locations or path between locations visited has changed + for any of the samples listed""" + example_samples = [ + "regular", + "lobed", + "core", + ] + for sample_type in example_samples: + _, planner = scan_test_helpers.example_smart_spiral(sample=sample_type) + expected_planner = ( + scan_test_helpers.get_expected_result_for_example_smart_spiral( + sample=sample_type + ) + ) + assert planner.path_history == expected_planner.path_history + assert planner.imaged_locations == expected_planner.imaged_locations diff --git a/tests/utilities/example_smart_spiral.pkl b/tests/utilities/example_smart_spiral.pkl deleted file mode 100644 index 5d5727d8..00000000 Binary files a/tests/utilities/example_smart_spiral.pkl and /dev/null differ diff --git a/tests/utilities/example_smart_spiral_core.pkl b/tests/utilities/example_smart_spiral_core.pkl new file mode 100644 index 00000000..ee598e73 Binary files /dev/null and b/tests/utilities/example_smart_spiral_core.pkl differ diff --git a/tests/utilities/example_smart_spiral_lobed.pkl b/tests/utilities/example_smart_spiral_lobed.pkl new file mode 100644 index 00000000..b64c0a67 Binary files /dev/null and b/tests/utilities/example_smart_spiral_lobed.pkl differ diff --git a/tests/utilities/example_smart_spiral_regular.pkl b/tests/utilities/example_smart_spiral_regular.pkl new file mode 100644 index 00000000..1ce6405b Binary files /dev/null and b/tests/utilities/example_smart_spiral_regular.pkl differ diff --git a/tests/utilities/scan_test_helpers.py b/tests/utilities/scan_test_helpers.py index 9b1b7a50..31794551 100644 --- a/tests/utilities/scan_test_helpers.py +++ b/tests/utilities/scan_test_helpers.py @@ -111,23 +111,19 @@ def interp_closed_path(xy_points: list[tuple[int, int]], n_points: int) -> MatPa return MatPath(path_points, closed=True) -def example_smart_spiral() -> tuple[FakeSample, scan_planners.ScanPlanner]: +def example_smart_spiral( + sample: str = "lobed", +) -> tuple[FakeSample, scan_planners.ScanPlanner]: """ Run an example scan and return the sample scanned and the planner object after scan is complete """ - xy_sample_points = [ - (-5000, -5000), - (-2000, 10000), - (1000, 2000), - (6000, 7000), - (9000, 2000), - ] + xy_sample_points = load_sample_points(sample=sample) sample = FakeSample(xy_sample_points) img_size = (1000, 1000) intial_position = (0, 0) - planner_settings = {"dx": 700, "dy": 700, "max_dist": 100000} - planner = scan_planners.ShortSmartSpiral( + planner_settings = {"dx": 1200, "dy": 800, "max_dist": 100000} + planner = scan_planners.SmartSpiral( intial_position=intial_position, planner_settings=planner_settings ) @@ -166,7 +162,7 @@ def profile_and_save_plot_for_example_smart_spiral(): run_stats.print_stats("scan_planners.py") -def update_example_smart_spiral_pickle(): +def update_example_smart_spiral_pickle(sample: str): """ Pickle the ScanPlanner for the example_smart_spiral(), this is done so the history can be compared by testing to check @@ -174,23 +170,54 @@ def update_example_smart_spiral_pickle(): If the algorithm is purposefully changed then this will need to be run to update the pickle for the test to pass. + + Takes sample, the sample type we have generated, so we can make a + pickle for each sample type """ - pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") + pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample}.pkl") with open(pkl_fname, "wb") as pkl_file_obj: - _, planner = example_smart_spiral() + _, planner = example_smart_spiral(sample=sample) pickle.dump(planner, pkl_file_obj, pickle.HIGHEST_PROTOCOL) -def get_expected_result_for_example_smart_spiral(): +def get_expected_result_for_example_smart_spiral(sample: str): """ Return the expected ScanPlanner object for the example_smart_spiral(), this is pickled, so that it can be committed. """ - pkl_fname = os.path.join(THIS_DIR, "example_smart_spiral.pkl") + pkl_fname = os.path.join(THIS_DIR, f"example_smart_spiral_{sample}.pkl") with open(pkl_fname, "rb") as pkl_file_obj: planner = pickle.load(pkl_file_obj) return planner +def load_sample_points(sample: str): + """Returns the points to generate a FakeSample of sample type "sample" """ + sample_options = { + "lobed": [ + (-5000, -5000), + (-2000, 16000), + (1000, 2000), + (6000, 7000), + (9000, 2000), + ], + "regular": [ + (-5000, -5000), + (-5000, 5000), + (5000, 5000), + (5000, -5000), + ], + "core": [ + (-12000, 2000), + (-12000, 1000), + (0, -2000), + (10000, -2000), + (10000, -1000), + (1000, 0), + ], + } + return sample_options[sample] + + if __name__ == "__main__": profile_and_save_plot_for_example_smart_spiral()