Merge branch 'snake-scan' into 'v3'

SnakeScan as a scan planner

See merge request openflexure/openflexure-microscope-server!464
This commit is contained in:
Julian Stirling 2026-02-09 17:10:12 +00:00
commit 9ab5a461c0
5 changed files with 475 additions and 86 deletions

View file

@ -18,6 +18,7 @@
}
},
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",
"snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow",
"stage_measure": "openflexure_microscope_server.things.stage_measure:RangeofMotionThing",
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"

View file

@ -13,6 +13,7 @@
}
},
"histo_scan_workflow": "openflexure_microscope_server.things.scan_workflows:HistoScanWorkflow",
"snake_workflow": "openflexure_microscope_server.things.scan_workflows:SnakeWorkflow",
"bg_color_channels_luv": "openflexure_microscope_server.things.background_detect:ColourChannelDetectLUV",
"bg_channel_deviations_luv": "openflexure_microscope_server.things.background_detect:ChannelDeviationLUV"
},

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import logging
from copy import copy
from typing import Any, Optional, TypeAlias
from typing import Any, Literal, Optional, TypeAlias
import numpy as np
@ -252,41 +252,15 @@ class ScanPlanner:
next_location = self._remaining_locations[0].xy_tuple
# If focussed locations exist return closest location, favouring most recent
closest_pos = self.closest_focus_site(next_location)
# Each scanner defines its own method of choosing a representative nearby site
closest_pos = self.select_nearby_focus_site(next_location)
z = None if closest_pos is None else closest_pos[2]
return next_location, z
def closest_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]:
"""Return the xyz position of the closest site where focus was achieved.
The most recently taken image is returned in the case of a tie.
:param xy_pos: The xy_position which the returned position should be closest
to.
Returns None if there if no focussed locations are present
"""
# save to variable rather than search for focussed sites each time.
focused_locations = self.focused_locations
if not focused_locations:
return None
# must be float64 (double precision) to deal with the huge numbers involved!
current_pos = np.array(xy_pos, dtype="float64")
path_pos = np.array(focused_locations, dtype="float64")[:, :2]
# Use linalg.norm to calculate the direct distance bweween the points
# Note linalg.norm always uses float64
dists = np.linalg.norm((path_pos - current_pos), axis=1)
# Get indices of all minima.
# Note np.where always returns a tuple of arrays, hence the trailing [0]
indices = np.where(dists == np.min(dists))[0]
# The last index is most recent
return focused_locations[indices[-1]]
def select_nearby_focus_site(self, next_location: XYPos) -> Optional[XYZPos]:
"""Return the focused site near xy_pos according to the tiebreak."""
raise NotImplementedError("Did you call the ScanPlanner base class?")
def mark_location_visited(
self, xyz_pos: XYZPos, imaged: bool, focused: bool
@ -316,6 +290,19 @@ class ScanPlanner:
)
)
def _grid_to_future_locations(
self,
grid: list[list[XYPos]],
) -> list[FutureScanLocation]:
"""Flatten a 2D grid of coordinates into flat list of FutureScanLocation objects.
:param grid: A 2D nested list of XY coordinates
:return: A flattened list of FutureScanLocations
"""
# Loop over each location in each line to flatten grid into single list.
return [FutureScanLocation(location) for line in grid for location in line]
class SmartSpiral(ScanPlanner):
"""A scan planner that spirals outward from the centre, prioritising short moves.
@ -385,7 +372,7 @@ class SmartSpiral(ScanPlanner):
def _initial_location_list(self) -> list[FutureScanLocation]:
"""Set the initial list of locations for this scan planner.
This is salled on initialisation.
This is called on initialisation.
For smart spiral this is just the first point
"""
@ -514,26 +501,6 @@ class SmartSpiral(ScanPlanner):
self._remaining_locations.sort(key=sort_key)
def get_next_location_and_z_estimate(self) -> tuple[XYPos, Optional[int]]:
"""Return the next location to scan and its estimated z-position.
This overrides the default behaviour of ScanPlanner to take the lowest value of
nearest neighbours as this works best for smart stack.
Note z-position may be None! This indicates that the current z position
should be used.
"""
if self.scan_complete:
raise RuntimeError("Can't get next position, scan is complete")
next_location = self._remaining_locations[0].xy_tuple
# If focused locations exist, return the neighbour with the lowest z position
closest_pos = self.select_nearby_focus_site(next_location)
z = None if closest_pos is None else closest_pos[2]
return next_location, z
def select_nearby_focus_site(self, xy_pos: XYPos) -> Optional[XYZPos]:
"""Return the xyz position of the nearby site with the lowest z position.
@ -575,7 +542,11 @@ class SmartSpiral(ScanPlanner):
# Choose the lowest (smallest z) of the neighbouring sites. Smart stack works best
# if started too low, so the lowest z will perform best
chosen_focused_site = min(focused_locations_array[indices], key=lambda x: x[-1])
candidates = focused_locations_array[indices]
min_z = np.min(candidates[:, -1])
# Find all with the minimum z, and select the latest
chosen_focused_site = candidates[candidates[:, -1] == min_z][-1]
# Convert back into list so values are of type int instead of np.int32
return tuple(chosen_focused_site.tolist())
@ -605,6 +576,77 @@ class SmartSpiral(ScanPlanner):
return np.max(np.abs(displacement_in_moves))
class SnakeScan(ScanPlanner):
"""A scan planner that performs a snake scan, right and down from a corner.
This planner starts at the corner of the region to scan, snaking back and forth,
starting moving right and down (assuming positive dx and dy.)
"""
_dx: int = 0
_dy: int = 0
_x_count: int = 0
_y_count: int = 0
def __init__(
self, initial_position: XYPos, planner_settings: Optional[dict] = None
) -> None:
"""Set up the lists inherited from ScanPlanner, plus a distance cutoff.
Use the supplied _dx and _dy to set a distance cutoff for an image to be
considered neighbouring another
"""
super().__init__(initial_position, planner_settings)
self._distance_cutoff: float = max([self._dx, self._dy]) * 1.1
def _parse(self, planner_settings: Optional[dict] = None) -> None:
"""Parse SnakeScan Settings dictionary.
* ``dx`` - the movement size in x
* ``dy`` - the movement size in y
* ``x_count`` - The number of columns in the scan.
* ``y_count`` - The number of rows in the scan.
"""
expected_keys = ["x_count", "y_count", "dx", "dy"]
invalid_msg = "SnakeScan 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._x_count = int(planner_settings["x_count"])
self._y_count = int(planner_settings["y_count"])
def _initial_location_list(self) -> list[FutureScanLocation]:
"""Set the initial list of locations for this scan planner.
This is called on initialisation.
For snake scan, this is the full grid, and none will be added during scanning.
"""
grid = create_rectangular_scan_path(
starting_pos=self._initial_position,
x_count=self._x_count,
y_count=self._y_count,
dx=self._dx,
dy=self._dy,
style="snake",
)
return self._grid_to_future_locations(grid)
# The noqa statement is because next_position is unused but is needed for equivalence
# with other workflows that require the next pos to select a neighbour.
def select_nearby_focus_site(self, next_position: XYPos) -> Optional[XYZPos]: # noqa: ARG002
"""For a snake scan, use the most recent focused site to predict focus."""
focused_locations = self.focused_locations
if not focused_locations:
return None
return focused_locations[-1]
def distance_between(
current_pos: XYPos | np.ndarray | FutureScanLocation,
next_pos: XYPos | np.ndarray | FutureScanLocation,
@ -620,3 +662,40 @@ def distance_between(
next_pos = np.array(next_pos, dtype="float64")
current_pos = np.array(current_pos, dtype="float64")
return float(np.linalg.norm(next_pos - current_pos))
def create_rectangular_scan_path(
starting_pos: XYPos,
x_count: int,
y_count: int,
dx: int,
dy: int,
style: Literal["snake", "raster"],
) -> list[list[XYPos]]:
"""Generate a 2D grid of (x, y) coordinates representing a rectangular scan path.
The grid is generated from starting_pos, and expanded in the
positive x and y directions using the provided step sizes. The scan order
can be either raster (left-to-right for every row) or snake (alternating
left-to-right and right-to-left per row).
:param starting_pos: Starting (x, y) position for the scan grid.
:param x_count: Number of points in the x-direction (columns).
:param y_count: Number of points in the y-direction (rows).
:param dx: Step size between points in the x-direction.
:param dy: Step size between points in the y-direction.
:param style: Scan pattern style. Either raster or snake.
:return: Nested list of (x, y) coordinates arranged by row.
"""
coords: list[list[XYPos]] = []
# Populate grid with coordinates in a regular grid
for y_index in range(y_count): # rows
row = [
(starting_pos[0] + x_index * dx, starting_pos[1] + y_index * dy)
for x_index in range(x_count)
]
if style == "snake" and y_index % 2 == 1:
row.reverse()
coords.append(row)
return coords

View file

@ -6,6 +6,7 @@ as well as specific workflows.
from __future__ import annotations
import os
from typing import (
Generic,
Mapping,
@ -17,7 +18,11 @@ from pydantic import BaseModel
import labthings_fastapi as lt
from openflexure_microscope_server.scan_planners import ScanPlanner, SmartSpiral
from openflexure_microscope_server.scan_planners import (
ScanPlanner,
SmartSpiral,
SnakeScan,
)
from openflexure_microscope_server.stitching import (
STITCHING_RESOLUTION,
StitchingSettings,
@ -33,6 +38,7 @@ from openflexure_microscope_server.things.background_detect import (
)
from openflexure_microscope_server.things.camera import BaseCamera
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
from openflexure_microscope_server.things.stage import BaseStage
from openflexure_microscope_server.ui import PropertyControl, property_control_for
SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
@ -59,6 +65,9 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232))
"""A tuple of the image resolution to capture."""
# CSM may not be set, and isn't required for a workflow. Allow for it to exist or be None
_csm: Optional[CameraStageMapper] = lt.thing_slot()
def check_before_start(self, scan_name: str) -> None:
"""Check before the scan starts. Throw an error if the scan shouldn't start.
@ -119,6 +128,44 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
"Each scan workflow must implement a settings_ui method."
)
def _require_csm(self) -> CameraStageMapper:
"""Give each model the option to require CSM. Return it if present."""
if self._csm is None:
raise RuntimeError(
"CameraStageMapping not set, and is required for this workflow."
)
return self._csm
def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]:
"""Use camera stage mapping to calculate x and y displacement from given overlap.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%.
:returns: (dx, dy) - the x and y displacements in steps
:raises RuntimeError: If there is no camera stage mapper Thing available or if CMS isn't calibrated.
"""
csm = self._require_csm()
csm_image_res = csm.image_resolution
if csm_image_res is None:
raise RuntimeError("CSM not set. Scan shouldn't have progresses this far.")
# Calculate displacements in image coordinates
dx_img = csm_image_res[1] * (1 - overlap)
dy_img = csm_image_res[0] * (1 - overlap)
x_move_stage = csm.convert_image_to_stage_coordinates(x=dx_img, y=0)
y_move_stage = csm.convert_image_to_stage_coordinates(x=0, y=dy_img)
# Assume no rotation or skew and take only the aligned axis of vector.
# Coerce to positive integer, but correct if x and y are flipped
if abs(x_move_stage["x"]) > abs(x_move_stage["y"]):
return x_move_stage["x"], y_move_stage["y"]
# If not use the other stage axes. Note "dx" will be the movement in camera y.
return y_move_stage["x"], x_move_stage["y"]
class HistoScanSettingsModel(BaseModel):
"""The settings for a scan with the HistoScanWorkflow.
@ -285,32 +332,6 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
return scan_settings, stitching_settings
def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]:
"""Use camera stage mapping to calculate x and y displacement.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%.
:returns: (dx, dy) - the x and y displacements in steps
"""
csm_image_res = self._csm.image_resolution
if csm_image_res is None:
raise RuntimeError("CSM not set. Scan shouldn't have progressed this far.")
# Calculate displacements in image coordinates
dx_img = csm_image_res[1] * (1 - overlap)
dy_img = csm_image_res[0] * (1 - overlap)
x_move_stage = self._csm.convert_image_to_stage_coordinates(x=dx_img, y=0)
y_move_stage = self._csm.convert_image_to_stage_coordinates(x=0, y=dy_img)
# Assume no rotation or skew and take only the aligned axis of vector.
# Coerce to positive integer, but correct if x and y are flipped
if abs(x_move_stage["x"]) > abs(x_move_stage["y"]):
return x_move_stage["x"], y_move_stage["y"]
# If not use the other stage axes. Note "dx" will be the movement in camera y.
return y_move_stage["x"], x_move_stage["y"]
def create_smart_stack_params(
self,
images_dir: str,
@ -478,3 +499,173 @@ class HistoScanWorkflow(ScanWorkflow[HistoScanSettingsModel]):
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
property_control_for(self, "max_range", label="Maximum Distance (steps)"),
]
class SnakeSettingsModel(BaseModel):
"""The settings for a scan with the SnakeWorkflow.
This includes settings calculated when starting. This will be held by smart scan
during a scan and serialised to disk.
"""
overlap: float
dx: int
dy: int
x_count: int
y_count: int
images_dir: str
autofocus_dz: int
save_resolution: tuple[int, int]
class SnakeWorkflow(ScanWorkflow[SnakeSettingsModel]):
"""A workflow optimised for snaking around samples.
This workflow generates a list of coordinates in a rectangle, and snakes
around them from the top left (assuming positive dx and dy).
"""
display_name: str = lt.property(default="Snake Scan", readonly=True)
ui_blurb: str = lt.property(
default=(
"This scan workflow is optimised for scanning over a rectangle. It "
"snakes down and right from the starting point, over a defined grid."
),
readonly=True,
)
_settings_model = SnakeSettingsModel
_planner_cls: type[ScanPlanner] = SnakeScan
# Thing Slots
_background_detector: ChannelDeviationLUV = lt.thing_slot()
_cam: BaseCamera = lt.thing_slot()
_csm: CameraStageMapper = lt.thing_slot()
_autofocus: AutofocusThing = lt.thing_slot()
_stage: BaseStage = lt.thing_slot()
# Scan settings
autofocus_dz: int = lt.setting(default=1000, ge=200, le=2000)
"""The z distance to perform an autofocus in steps.
Must be greater than or equal to 200, and less than or equal to 2000.
"""
overlap: float = lt.setting(default=0.45, ge=0.1, le=0.7)
"""The fraction that adjacent images should overlap in x or y.
This must be between 0.1 and 0.7.
"""
x_count: int = lt.setting(default=3)
y_count: int = lt.setting(default=2)
# The noqa statement is because scan_name is unused but is needed for equivalence
# with other workflows that may want to validate the scan name.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that camera-stage-mapping is set.
Raise error if:
- camera stage mapping is not set
"""
if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.")
@lt.property
def ready(self) -> bool:
"""Whether this scanworkflow is ready to start."""
return not self._csm.calibration_required
def all_settings(
self, images_dir: str
) -> tuple[SnakeSettingsModel, StitchingSettings]:
"""Return the workflow and stitching settings.
:param images_dir: The directory that images are to be written to.
:return: A tuple containing the settings model for this workflow and the
settings model for stitching.
"""
stitching_settings = StitchingSettings(
overlap=self.overlap,
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
)
dx, dy = self._calc_displacement_from_overlap(self.overlap)
self.logger.info(
f"Based on an overlap of {self.overlap}, the stage will make steps of "
f"{dx}, {dy}"
)
scan_settings = SnakeSettingsModel(
overlap=self.overlap,
dx=dx,
dy=dy,
x_count=self.x_count,
y_count=self.y_count,
images_dir=images_dir,
autofocus_dz=self.autofocus_dz,
save_resolution=self.save_resolution,
)
return scan_settings, stitching_settings
def pre_scan_routine(self, settings: SnakeSettingsModel) -> None:
"""Autofocus before starting the scan.
:param settings: The settings for this scan as a SnakeSettingsModel
"""
self._autofocus.looping_autofocus(dz=settings.autofocus_dz, start="centre")
def new_scan_planner(
self, settings: SnakeSettingsModel, position: Mapping[str, int]
) -> ScanPlanner:
"""Return a new scan planner object.
:param settings: The settings for this scan as a SnakeSettingsModel
:param position: The starting position as a mapping of axes names to int.
"""
# The initial plan for the scan should be a single x,y position. All future
# moves will be planned around this point. In future, route planner could
# have multiple starting positions, each of which will be visited before the
# scan can end.
planner_settings = {
"dx": settings.dx,
"dy": settings.dy,
"x_count": settings.x_count,
"y_count": settings.y_count,
}
return self._planner_cls(
initial_position=(position["x"], position["y"]),
planner_settings=planner_settings,
)
def acquisition_routine(
self, settings: SnakeSettingsModel, xyz_pos: tuple[int, int, int]
) -> tuple[bool, Optional[int]]:
"""Perform acquisition routine. This is run at each scan location.
:param settings: The settings for this scan as a SnakeSettingsModel
:param xyz_pos: The current position as a tuple or 3 ints.
:return: A tuple of whether an image was taken, and the z-position for focus.
If failed to find focus, returns for the focus z-position.
"""
self._autofocus.fast_autofocus(dz=settings.autofocus_dz)
focus_height = self._stage.get_xyz_position()[2]
filename = f"img_{xyz_pos[0]}_{xyz_pos[1]}_{focus_height}.jpeg"
self._cam.capture_and_save(
jpeg_path=os.path.join(settings.images_dir, filename),
save_resolution=settings.save_resolution,
)
imaged = True
return imaged, focus_height
@lt.property
def settings_ui(self) -> list[PropertyControl]:
"""A list of PropertyControl objects to create the settings in the scan tab."""
return [
property_control_for(self, "overlap", label="Image Overlap (0.1-0.7)"),
property_control_for(self, "x_count", label="Number of columns"),
property_control_for(self, "y_count", label="Number of rows"),
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
]

View file

@ -143,6 +143,8 @@ def test_smart_spiral_first_few_pos():
# if we mark this position as visited, imaged, and focused
planner.mark_location_visited(xyz_pos1, imaged=True, focused=True)
# current visited path is [[100, 50, 10]]
# scan is not complete
assert not planner.scan_complete
@ -172,6 +174,8 @@ def test_smart_spiral_first_few_pos():
# if we mark this position as visited, imaged, and NOT focused
planner.mark_location_visited(xyz_pos2, imaged=True, focused=False)
# current visited path is [[100, 50, 10], [50, 50, 10]]
# Check this position remove from planned
assert xy_pos2 not in planner.remaining_locations
# Check original position not re-added
@ -195,19 +199,22 @@ def test_smart_spiral_first_few_pos():
assert z_pos3 is z_focus
# Check that the closest focus site to pos3 is pos 1 as
# pos 2 is not focussed
assert planner.closest_focus_site(xy_pos3) == xyz_pos1
assert planner.select_nearby_focus_site(xy_pos3) == xyz_pos1
new_z_focus = 20
xyz_pos3 = (xy_pos3[0], xy_pos3[1], new_z_focus)
# Finally check that if this is focused...
planner.mark_location_visited(xyz_pos3, imaged=True, focused=True)
# current visited path is [[100, 50, 10], [50, 50, 10], [50, 0, 20]]
# ... then the new 4th point ...
xy_pos4, z_pos4 = planner.get_next_location_and_z_estimate()
# ...(100, 0)...
assert xy_pos4 == (100, 0)
# ... and it should get its focus from the lowest neighbouring point
# lowest neighbour to [100, 0] is [100, 50, 10]
assert z_pos4 is z_focus
assert planner.closest_focus_site(xy_pos4) == xyz_pos3
assert planner.select_nearby_focus_site(xy_pos4) == xyz_pos1
def test_smart_spiral_stops_on_max_dist():
@ -265,20 +272,20 @@ def test_closest_focus_with_large_numbers():
scan_planners.VisitedScanLocation((1000000, 0, 0), imaged=True, focused=True),
scan_planners.VisitedScanLocation((0, 1000000, 0), imaged=True, focused=True),
]
assert planner.closest_focus_site((0, 0)) == (0, 1000000, 0)
assert planner.select_nearby_focus_site((0, 0)) == (0, 1000000, 0)
# Try similar
planner._path_history = [
scan_planners.VisitedScanLocation((1234567, 0, 0), imaged=True, focused=True),
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
]
assert planner.closest_focus_site((0, 0)) == (-1234567, 0, 0)
assert planner.select_nearby_focus_site((0, 0)) == (-1234567, 0, 0)
# Make the first point 1 step closer
planner._path_history = [
scan_planners.VisitedScanLocation((1234566, 0, 0), imaged=True, focused=True),
scan_planners.VisitedScanLocation((-1234567, 0, 0), imaged=True, focused=True),
]
assert planner.closest_focus_site((0, 0)) == (1234566, 0, 0)
assert planner.select_nearby_focus_site((0, 0)) == (1234566, 0, 0)
def test_example_smart_spiral():
@ -308,3 +315,113 @@ def test_example_smart_spiral():
assert planner.path_history == expected_planner.path_history
assert planner.imaged_locations == expected_planner.imaged_locations
def test_snake_scan_basic_grid():
"""Check that SnakeScan generates a single point for a 1x1 scan."""
initial_position = (100, 50)
planner_settings = {"dx": 100, "dy": 100, "x_count": 1, "y_count": 1}
planner = scan_planners.SnakeScan(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert not planner.scan_complete
# When we start it should want to stay in the initial pos and have
# no z_estimate
xy_pos, z_pos = planner.get_next_location_and_z_estimate()
assert xy_pos == initial_position
assert z_pos is None
# Try to mark location as imaged with only xy_position
with pytest.raises(ValueError, match="3 value tuple expected"):
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
# if scan is complete, asking for the next location returns an error
with pytest.raises(RuntimeError):
planner.get_next_location_and_z_estimate()
def test_snake_scan_basic_length():
"""SnakeScan should generate the correct number of locations."""
initial_position = (100, 50)
planner_settings = {"dx": 100, "dy": 100, "x_count": 3, "y_count": 4}
planner = scan_planners.SnakeScan(
initial_position=initial_position,
planner_settings=planner_settings,
)
coords = planner.remaining_locations
assert len(coords) == 3 * 4
def test_snake_scan_ordering():
"""Test that snake scan returns a path in the right order."""
initial_position = (0, 0)
planner_settings = {"dx": 10, "dy": 10, "x_count": 4, "y_count": 3}
planner = scan_planners.SnakeScan(
initial_position=initial_position,
planner_settings=planner_settings,
)
coords = planner.remaining_locations
expected = [
(0, 0),
(10, 0),
(20, 0),
(30, 0),
(30, 10),
(20, 10),
(10, 10),
(0, 10),
(0, 20),
(10, 20),
(20, 20),
(30, 20),
]
assert coords == expected
def test_snake_scan_single_row():
"""Test edge case of a single row scan."""
initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 4, "y_count": 1}
planner = scan_planners.SnakeScan(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert planner.remaining_locations == [(0, 0), (5, 0), (10, 0), (15, 0)]
def test_snake_scan_single_column():
"""Test edge case of a single column scan."""
initial_position = (0, 0)
planner_settings = {"dx": 5, "dy": 5, "x_count": 1, "y_count": 4}
planner = scan_planners.SnakeScan(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert planner.remaining_locations == [
(0, 0),
(0, 5),
(0, 10),
(0, 15),
]