openflexure-microscope-server/tests/unit_tests/test_scan_planners.py
2026-02-11 11:03:08 +00:00

445 lines
15 KiB
Python

"""Test the scan planning algorithms of the Microscope.
As well as low level function by function tests, this test suite also provides tests
that simulate scanning a sample, checking that the expected path is followed.
"""
from copy import copy
import pytest
from openflexure_microscope_server import scan_planners
from .utilities import scan_test_helpers
def test_enforce_xy_tuple():
"""Check that 2 value tuples (or ValueErrors) are always returned."""
bad_len_vals = [[1], [], (1,), (2, 4, 4), [1, 4, 5, 7]]
for value in bad_len_vals:
with pytest.raises(ValueError, match="2 value tuple expected"):
scan_planners.enforce_xy_tuple(value)
bad_type_vals = ["hi", 1, {"this": "that"}, {1, 2}]
for value in bad_type_vals:
with pytest.raises(TypeError):
scan_planners.enforce_xy_tuple(value)
assert scan_planners.enforce_xy_tuple((1, 6)) == (1, 6)
assert scan_planners.enforce_xy_tuple([1, 6]) == (1, 6)
def test_enforce_xyz_tuple():
"""Check that 3 value tuples (or ValueErrors) are always returned."""
bad_len_vals = [[1], [], (1,), (2, 4), [1, 4, 5, 7]]
for value in bad_len_vals:
with pytest.raises(ValueError, match="3 value tuple expected"):
scan_planners.enforce_xyz_tuple(value)
bad_type_vals = ["hi!", 1, {"this": "that"}, {1, 2, 3}]
for value in bad_type_vals:
with pytest.raises(TypeError):
scan_planners.enforce_xyz_tuple(value)
assert scan_planners.enforce_xyz_tuple((1, 6, 2)) == (1, 6, 2)
assert scan_planners.enforce_xyz_tuple([1, 6, 6]) == (1, 6, 6)
def test_base_class_not_implemented():
"""Check NotImplementedError is raised when initialising ScanPlanner directly."""
initial_position = (100, 50)
with pytest.raises(NotImplementedError):
scan_planners.ScanPlanner(initial_position=initial_position)
def test_v_basic_smart_spiral():
"""Check that a SmartSpiral where the first image is not sample completes."""
initial_position = (100, 50)
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
planner = scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=planner_settings
)
# Create a planner. It shouldn't be complete.
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_bad_smart_spiral_settings():
"""Check that KeyError is raised when SmartSpiral is given bad settings."""
initial_position = (100, 50)
# Class init should raise error if no planner_settings dictionary set
msg = "RectGridPlanner requires planner_settings with keys"
with pytest.raises(KeyError, match=msg):
scan_planners.SmartSpiral(initial_position=initial_position)
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
keys = ["dx", "dy", "max_dist"]
# Class init should raise error if planner_settings is missing any key
for delkey in keys:
bad_planner_settings = copy(planner_settings)
del bad_planner_settings[delkey]
with pytest.raises(KeyError):
scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=bad_planner_settings
)
# Class init should raise error if any value in planner_settings can't be cast
# to int
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"
with pytest.raises(ValueError, match="invalid literal for int()"):
scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=bad_planner_settings
)
def test_smart_spiral_first_few_pos():
"""Test for correct data addition during initial scan positions.
This is a very long test, not strictly a "unit" test. It checks step by step
that data is added correctly for the first few positions in a scan. It is
intended to catch basic issues if the algorithm is updated.
"""
initial_position = (100, 50)
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
# Create a planner
planner = scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=planner_settings
)
# it shouldn't start complete
assert not planner.scan_complete
# When we start it should want to stay in the initial pos and have
# no z_estimate
xy_pos1, z_pos1 = planner.get_next_location_and_z_estimate()
assert xy_pos1 == initial_position
assert z_pos1 is None
# Set a focus value
z_focus = 10
xyz_pos1 = (xy_pos1[0], xy_pos1[1], z_focus)
# 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
# Lists should have updated to add the position to histories
assert xyz_pos1 in planner.imaged_locations
assert xyz_pos1 in planner.focused_locations
assert xy_pos1 in planner.path_history
# remove from planned
assert xy_pos1 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 updated
xy_pos2, z_pos2 = planner.get_next_location_and_z_estimate()
# move in negative x-dir first
assert xy_pos2 == (50, 50)
# Focus set from closest point
assert z_pos2 is z_focus
xyz_pos2 = (xy_pos2[0], xy_pos2[1], z_focus)
# 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
assert xy_pos1 not in planner.remaining_locations
# 3 old points still planned
assert (100, 0) in planner.remaining_locations
assert (100, 100) in planner.remaining_locations
assert (150, 50) in planner.remaining_locations
# Add 3 new points to planned
assert (0, 50) in planner.remaining_locations
assert (50, 100) in planner.remaining_locations
assert (50, 0) in planner.remaining_locations
assert len(planner.remaining_locations) == 6
# Now the next location is updated
xy_pos3, z_pos3 = planner.get_next_location_and_z_estimate()
# move in negative y-dir first next
assert xy_pos3 == (50, 0)
# Focus still set as before
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.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.select_nearby_focus_site(xy_pos4) == xyz_pos1
def test_smart_spiral_stops_on_max_dist():
"""Test that if max distance is reached smart spiral really does stop."""
initial_position = (0, 0)
planner_settings = {"dx": 100, "dy": 100, "max_dist": 1000}
# Create a planner
planner = scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=planner_settings
)
while not planner.scan_complete:
xy_pos, _ = planner.get_next_location_and_z_estimate()
xyz_pos = (xy_pos[0], xy_pos[1], 0)
planner.mark_location_visited(xyz_pos, imaged=True, focused=True)
# Estimate of radius = 10 scans, pir^2 = 314 images. In reality it works out as
# 317
assert len(planner.path_history) == 317
def test_mark_wrong_location():
"""Check that an error is raised if a scan marks the wrong location as visited."""
initial_position = (100, 50)
planner_settings = {"dx": 50, "dy": 50, "max_dist": 10000}
# Create a planner
planner = scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=planner_settings
)
xy_pos, _ = planner.get_next_location_and_z_estimate()
# Trying to mark the wrong location as visited raises error
wrong_xyz_pos = (xy_pos[0] + 1, xy_pos[1], 0)
with pytest.raises(RuntimeError):
planner.mark_location_visited(wrong_xyz_pos, imaged=True, focused=True)
def test_closest_focus_with_large_numbers():
"""Tests to check that everything works well with huge numbers of steps.
The number of steps gets very large on the micorscope. But most of the tests
above use smaller numbers for clarity.
"""
initial_position = (0, 0)
# Set this up, but we won't use the settings
planner_settings = {"dx": 10000, "dy": 10000, "max_dist": 100000}
# Create a planner
planner = scan_planners.SmartSpiral(
initial_position=initial_position, planner_settings=planner_settings
)
# Directly overwrite the private path history locations list for test
# For two points 1m points away it should choose the last as they are equal
planner._path_history = [
scan_planners.VisitedScanLocation((1000000, 0, 0), imaged=True, focused=True),
scan_planners.VisitedScanLocation((0, 1000000, 0), imaged=True, focused=True),
]
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.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.select_nearby_focus_site((0, 0)) == (1234566, 0, 0)
def test_example_smart_spiral():
"""Test the smart spiral scan algorithm on the different sample types.
The sample types:
* ``regular``
* ``lobed``
* ``core"``
These are defined in scan_test_helpers.load_sample_points
This 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_name in example_samples:
_, planner = scan_test_helpers.example_smart_spiral(sample_name)
expected_planner = (
scan_test_helpers.get_expected_result_for_example_smart_spiral(sample_name)
)
assert planner.path_history == expected_planner.path_history
assert planner.imaged_locations == expected_planner.imaged_locations
def test_snake_planner_basic_grid():
"""Check that snake scan planner generates a single point for a 1x1 scan."""
initial_position = (100, 50)
planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 1,
"y_count": 1,
"style": "snake",
}
planner = scan_planners.RegularGridPlanner(
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():
"""Snake scan planner should generate the correct number of locations."""
initial_position = (100, 50)
planner_settings = {
"dx": 100,
"dy": 100,
"x_count": 3,
"y_count": 4,
"style": "snake",
}
planner = scan_planners.RegularGridPlanner(
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,
"style": "snake",
}
planner = scan_planners.RegularGridPlanner(
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, "style": "snake"}
planner = scan_planners.RegularGridPlanner(
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, "style": "snake"}
planner = scan_planners.RegularGridPlanner(
initial_position=initial_position,
planner_settings=planner_settings,
)
assert planner.remaining_locations == [
(0, 0),
(0, 5),
(0, 10),
(0, 15),
]