Merge branch 'rom_test_only' into 'v3'

Adding automatic range of motion measurements

See merge request openflexure/openflexure-microscope-server!335
This commit is contained in:
Julian Stirling 2025-10-27 12:18:31 +00:00
commit 94c9a0e7dd
7 changed files with 1244 additions and 4 deletions

View file

@ -17,3 +17,7 @@ class MockCSMThing:
"""
image_resolution = (123, 456)
image_to_stage_displacement_matrix = [
[0.03061156624485296, 1.8031242270940833],
[1.773236372778601, 0.006660431608601435],
]

View file

@ -16,4 +16,4 @@ class MockStageThing:
is not artificially inflated.
"""
position = (111, 222, 333)
position = {"x": 3635, "y": 10, "z": 617}

600
tests/test_stage_measure.py Normal file
View file

@ -0,0 +1,600 @@
"""File contains unit tests for stage_measure."""
from copy import copy
import logging
import dataclasses
import tempfile
from fastapi.testclient import TestClient
import numpy as np
import pytest
import labthings_fastapi as lt
from openflexure_microscope_server.things import stage_measure
LOGGER = logging.getLogger("mock-invocation_logger")
# Useful generators
def increasing_xy_dict_generator(*_args, **_kwargs):
"""Generate x-y dictionaries of incrementing sizes.
These don't simulate expected effects, but allow checking sequential reads of a
function/property were used.
"""
i = 0
while True:
yield {"x": i, "y": i}
i += 1
def increasing_xyz_dict_generator(*_args, **_kwargs):
"""Generate x-y-z dictionaries of incrementing sizes.
These don't simulate expected effects, but allow checking sequential reads of a
function/property were used.
"""
i = 0
while True:
yield {"x": i, "y": i, "z": i}
i += 1
@pytest.fixture
def csm_matrix():
"""Return an example CSM matrix."""
return [
[0.03061156624485296, -1.8031242270940833],
[1.773236372778601, 0.006660431608601435],
]
@pytest.fixture
def example_rom_data():
"""Return some example data in a RomDataTracker."""
mock_positions = [
{"x": 0, "y": 0, "z": 42},
{"x": 727, "y": 2, "z": 154},
{"x": 1454, "y": 4, "z": 228},
{"x": 2181, "y": 6, "z": 351},
{"x": 2908, "y": 8, "z": 509},
{"x": 3635, "y": 10, "z": 617},
]
rom_data = stage_measure.RomDataTracker()
# loop through mock positions recording them with an offset.
for position in mock_positions:
offset = {"x": 54.4, "y": 0}
rom_data.record_movement(position, offset)
return rom_data
def test_predict_z(example_rom_data):
"""Check that the prediction for the next z position is correct."""
mock_z_diff = example_rom_data.predict_z_displacement(
axis="x",
stage_movement={"x": 5243, "y": 0},
stage_position={"x": 3635, "y": 10, "z": 617},
)
expected_z_diff = 1343
assert mock_z_diff == expected_z_diff
@pytest.mark.parametrize(
("movement", "axis", "other_axis"),
[
({"x": 2908, "y": 0}, "x", "y"),
({"x": -29, "y": 0}, "x", "y"),
({"x": 0, "y": 123}, "y", "x"),
({"x": 0, "y": -456}, "y", "x"),
],
)
def test_axis_from_movement_dict(movement, axis, other_axis):
"""Test that _axis_from_movement_dict identifies the correct axes."""
assert stage_measure._axis_from_movement_dict(movement) == axis
ret_axes = stage_measure._axis_from_movement_dict(movement, return_other=True)
assert ret_axes == (axis, other_axis)
@pytest.mark.parametrize("movement", [{"x": 0, "y": 0}, {"x": 10, "y": 10}])
def test_error_on_axis_from_movement_dict(movement):
"""Check _axis_from_movement_dict errors if both axes are zero, or both are non-zero."""
with pytest.raises(ValueError, match="Either x or y movement should be zero"):
assert stage_measure._axis_from_movement_dict(movement)
@pytest.mark.parametrize(
("par_fraction", "too_high"),
[
(-0.20, True),
(-0.11, True),
(-0.09, False),
(-0.05, False),
(0.00, False),
(0.05, False),
(0.09, False),
(0.11, True),
(0.20, True),
],
)
def test_parasitic_detect(par_fraction, too_high):
"""Check parasitic motion is detected if the fraction of parasitic motion is too high."""
movement = {"x": 2908, "y": 0}
offset = copy(movement)
offset["y"] = movement["x"] * par_fraction
detected = stage_measure._parasitic_motion_detected(
movement=movement, offset=offset
)
assert detected == too_high
def test_error_if_no_stream_res_set_when_requesting_img_coords():
"""Check a RuntimeError thrown when requesting image coordinates if resolution unset."""
with pytest.raises(RuntimeError, match="Stream resolution must be set"):
stage_measure.RangeofMotionThing()._img_percentage_to_img_coords(20, "x")
@pytest.fixture
def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing:
"""Yield a RangeofMotionThing already populated with some example rom_data."""
rom_thing = stage_measure.RangeofMotionThing()
rom_thing._stream_resolution = [800, 600]
rom_thing._rom_data = example_rom_data
with tempfile.TemporaryDirectory() as tmpdir:
server = lt.ThingServer(settings_folder=tmpdir)
server.add_thing(rom_thing, "/rom_thing/")
with TestClient(server.app):
yield rom_thing
@pytest.fixture
def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
"""Return a RomDeps object full of mocks, except the logger which is LOGGER."""
def apply_csm(x: float, y: float) -> dict[str, int]:
"""Convert image coordinates to stage coordinates."""
vec = np.dot(np.array([y, x]), np.array(csm_matrix))
return {"x": int(vec[0]), "y": int(vec[1])}
mock_cam = mocker.Mock()
mock_cam.image_is_sample.return_value = (True, "Mocked not measured.")
mock_csm = mocker.Mock()
# Set up mock csm to return a CSM matrix
mock_csm.image_to_stage_displacement_matrix = csm_matrix
mock_csm.convert_image_to_stage_coordinates.side_effect = apply_csm
return stage_measure.RomDeps(
autofocus=mocker.Mock(),
stage=mocker.Mock(),
cam=mock_cam,
csm=mock_csm,
logger=LOGGER,
)
def test_offset_from(rom_thing, mock_rom_deps, mocker):
"""Check the calls and returns for RangeofMotionThing._offset_from."""
# Set up mock for the FFT displacement
disp_between_route = (
"openflexure_microscope_server.things.stage_measure."
"fft_image_tracking.displacement_between_images"
)
mock_disp_between = mocker.patch(
disp_between_route,
return_value=[123, 456],
)
# Run it
offset = rom_thing._offset_from(before_img="MOCK_IMAGE", rom_deps=mock_rom_deps)
# Check the offset is a dictionary with the correct values for the axes
assert offset["x"] == 456
assert offset["y"] == 123
# Check 1 image was taken
assert mock_rom_deps.cam.grab_as_array.call_count == 1
mock_after_image = mock_rom_deps.cam.grab_as_array.return_value
# Check the FFT displacement was called once
assert mock_disp_between.call_count == 1
displacement_kwargs = mock_disp_between.call_args.kwargs
# Check the image inputs
assert displacement_kwargs["image_0"] == "MOCK_IMAGE"
assert displacement_kwargs["image_1"] == mock_after_image
@pytest.mark.parametrize("perform_autofocus", [True, False])
def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker):
"""Test _move_and_measure with and without initial autofocus checking call counts.
This doesn't test with the repeate autofocus if motion isn't detected
"""
mock_offset_value = {"x": 100, "y": 3}
mocker.patch.object(rom_thing, "_offset_from", return_value=mock_offset_value)
movement = {"x": 100, "y": 0}
offset = rom_thing._move_and_measure(
movement=movement, rom_deps=mock_rom_deps, perform_autofocus=perform_autofocus
)
# Check exactly 1 move
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
# The kwargs of the call should movement dict
assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement
# Check autofocus call count
expected_af_count = 1 if perform_autofocus else 0
assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count
# And check final return
assert offset == mock_offset_value
@pytest.mark.parametrize(
("x_offsets", "n_offset_measures", "expected_return"),
[
([5.1, 0.2], 1, {"x": 5.1, "y": 0}),
([-5.1, 0.2], 1, {"x": -5.1, "y": 0}),
([0.1, 5.2, 0.3, 0.4, 0.5], 2, {"x": 5.2, "y": 0}),
([0.1, 0.2, 5.3, 0.4, 0.5], 3, {"x": 5.3, "y": 0}),
([0.1, 0.2, 0.3, 5.4, 0.5], 4, {"x": 5.4, "y": 0}),
# n_offset_measures shouldn't go higher than 4 as max autofocus repeats is 3
([0.1, 0.2, 0.3, 0.4, 5.5], 4, {"x": 0.4, "y": 0}),
],
)
def test_move_and_measure_with_refocus(
x_offsets, n_offset_measures, expected_return, rom_thing, mock_rom_deps, mocker
):
"""Test _move_and_measure with final refocus if offset is too small."""
return_dicts = tuple({"x": x, "y": 0} for x in x_offsets)
offset_from_mock = mocker.patch.object(
rom_thing, "_offset_from", side_effect=return_dicts
)
movement = {"x": 10, "y": 0}
offset = rom_thing._move_and_measure(
movement=movement,
rom_deps=mock_rom_deps,
perform_autofocus=False,
max_autofocus_repeats=3,
abs_min_offset=5,
)
# Check exactly 1 move
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
# Check expected _offset_from calls
assert offset_from_mock.call_count == n_offset_measures
# The kwargs of the call should movement dict
assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement
# Check autofocus call count is 1 less than number of offset measures as no autofocus
# is performed before the first one
expected_af_count = n_offset_measures - 1
assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count
# And check final return
assert offset == expected_return
def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker):
"""Check error if abs_min_offset is not positive when using it to determine if to autofocus."""
offset_from_mock = mocker.patch.object(
rom_thing, "_offset_from", return_value={"x": 0, "y": 0}
)
# First check with abs_min_offset not set. The default should be zero.
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
rom_thing._move_and_measure(
movement={"x": 10, "y": 0},
rom_deps=mock_rom_deps,
perform_autofocus=False,
max_autofocus_repeats=3,
)
# Then check with abs_min_offset negative, this might happen if the the expected
# move calculation is not made absolute.
with pytest.raises(ValueError, match="abs_min_offset must be positive"):
rom_thing._move_and_measure(
movement={"x": 10, "y": 0},
rom_deps=mock_rom_deps,
perform_autofocus=False,
max_autofocus_repeats=3,
abs_min_offset=-123.456,
)
# Should have never measured and offset. Just error straight away.
assert offset_from_mock.call_count == 0
def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker):
"""Check that _move_back_until_motion_detected is making increasing negative moves.
The moves for this method should be in opposite direction to the direction
specified as this is moving back after the stage reaches end of its movement.
"""
mock_move_n_meas = mocker.patch.object(
rom_thing, "_move_and_measure", return_value={"x": 0, "y": 0}
)
with pytest.raises(RuntimeError, match="Cannot detect motion again"):
rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps)
max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP)
expected_step = 800 * stage_measure.SMALL_STEP / 100
assert mock_move_n_meas.call_count == max_tries
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
call_args.kwargs["movement"] = {"x": 0, "y": expected_step}
call_args.kwargs["perform_autofocus"] = False
## Reset mock and change the side effect
mock_move_n_meas.reset_mock()
mock_move_n_meas.side_effect = (
{"x": 0, "y": 0},
{"x": 0, "y": 0},
{"x": expected_step * 0.8, "y": 0},
)
# Other axis and direction this time
rom_thing._move_back_until_motion_detected("x", 1, rom_deps=mock_rom_deps)
# Should only be called 3 times
assert mock_move_n_meas.call_count == 3
for _i, call_args in enumerate(mock_move_n_meas.call_args_list):
call_args.kwargs["movement"] = {"x": -expected_step, "y": 0}
call_args.kwargs["perform_autofocus"] = False
@pytest.mark.parametrize(
("good_moves", "expected_to_detect_motion", "offset_calls"),
[
([0, 1, 2], True, 3), # First 3 pass all good
([1, 2, 3], True, 4), # First is bad, will refocus, still complete
([2, 3, 4], True, 5), # First 2 are bad, will refocus twice, still complete
([3, 4, 5], True, 6), # First 3 are bad, will refocus 3 times, still complete
([4, 5, 6], False, 4), # First 4 are bad, fails
# Check second and 3rd measurement can fail after first fails 3 times
([3, 5, 7], True, 8),
([3, 6, 9], True, 10),
([3, 7, 11], True, 12),
# But they can't fail 4 times
([3, 8, 9], False, 8),
([3, 7, 12], False, 12),
],
)
def test_stage_still_moves(
good_moves,
expected_to_detect_motion,
offset_calls,
rom_thing,
mock_rom_deps,
mocker,
):
"""Test _stage_still_moves correctly detects stage movement."""
min_offset = 800 * stage_measure.SMALL_STEP / 100 * stage_measure.DETECT_MOTION_TOL
def gen_offsets(*_args, **_kwargs):
"""Generate offset dictionaries with small moves unless count matches ``good_moves``."""
i = 0
while True:
x = min_offset * 1.2 if i in good_moves else 0.1
yield {"x": x, "y": 0}
i += 1
mock_offset_from = mocker.patch.object(
rom_thing, "_offset_from", side_effect=gen_offsets()
)
still_moves = rom_thing._stage_still_moves(
axis="x",
direction=1,
rom_deps=mock_rom_deps,
)
assert still_moves is expected_to_detect_motion
assert mock_offset_from.call_count == offset_calls
def test_big_z_corrected_movement(rom_thing, mock_rom_deps):
"""Check big z corrected move moves in x/y and z the expected distances."""
mock_rom_deps.stage.position = {"x": 5000, "y": 30, "z": 500}
rom_thing._big_z_corrected_movement("x", direction=1, rom_deps=mock_rom_deps)
expected_movement = {"x": 800 * stage_measure.BIG_STEP / 100, "y": 0}
# Check there is one z move in steps
assert mock_rom_deps.stage.move_relative.call_count == 1
move_kwargs = mock_rom_deps.stage.move_relative.call_args.kwargs
assert "x" not in move_kwargs
assert "y" not in move_kwargs
assert "z" in move_kwargs
assert move_kwargs["z"] == 1148
# And one move in image coordinates
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
lat_mov_kwargs = mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs
assert lat_mov_kwargs == expected_movement
def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
"""Check the initial moves are of the correct size and are recorded."""
# Mock the _offset_from and stage.position to return generated dictionaries that
# increment each time they are called. (All values 0 the first time, all values 1
# the second time ...)
mocker.patch.object(
rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator()
)
type(mock_rom_deps.stage).position = mocker.PropertyMock(
side_effect=increasing_xyz_dict_generator()
)
expected_movement = {"x": -800 * stage_measure.MEDIUM_STEP / 100, "y": 0}
# Remove the mock RomData before starting
rom_thing._rom_data = stage_measure.RomDataTracker()
# Run it!
rom_thing._initial_moves_for_z_prediction("x", direction=-1, rom_deps=mock_rom_deps)
# Check that _rom_data now contains the 5 mocked returns in order.
assert rom_thing._rom_data.offsets == [{"x": i, "y": i} for i in range(5)]
assert rom_thing._rom_data.stage_coords == [
{"x": i, "y": i, "z": i} for i in range(5)
]
# Check that the csm movement function is called 5 times
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 5
# Each time with the expected movement
for arg_list in mock_rom_deps.csm.move_in_image_coordinates.call_args_list:
assert arg_list.kwargs == expected_movement
def test_move_until_edge_error(rom_thing, mock_rom_deps, mocker):
"""Check that if there is an error while moving to the edge the stage returns to start."""
mock_position_dict = {"x": 123, "y": 456, "z": 789}
type(mock_rom_deps.stage).position = mocker.PropertyMock(
return_value=mock_position_dict
)
mocker.patch.object(
rom_thing, "_initial_moves_for_z_prediction", side_effect=RuntimeError("Mock")
)
# Remove the mock RomData before starting
rom_thing._rom_data = stage_measure.RomDataTracker()
# Error should be raised even though it is in the Try:
with pytest.raises(RuntimeError, match="Mock"):
rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps)
# However the "finally" should have executed returning to the starting position
assert mock_rom_deps.stage.move_absolute.call_count == 1
abs_move_kwargs = mock_rom_deps.stage.move_absolute.call_args.kwargs
expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True)
assert abs_move_kwargs == expected_abs_move_kwargs
def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
"""Check move until edge runs the correct movement sequence."""
# Remove the mock RomData before starting
rom_thing._rom_data = stage_measure.RomDataTracker()
mock_rom_deps.stage.position = {"x": "mock", "y": "starting", "z": "pos"}
def add_fake_initial_positions(*_args, **_kwargs):
"""Rather than run initial moves just add some fake data."""
for i in range(5):
rom_thing._rom_data.record_movement(f"mock-init-pos{i + 1}", "mock-offset")
def update_stage_pos_on_big_move(*_args, **_kwargs):
"""With big moves update the stage position."""
big_move_count = 1
while True:
mock_rom_deps.stage.position = f"mocked-big-move-pos{big_move_count}"
yield
big_move_count += 1
def set_final_pos(*_args, **_kwargs):
"""When move_back_until_motion_detected is run set a final stage position."""
mock_rom_deps.stage.position = "mock-final-pos"
# Mock the main movement functions
mock_init_moves = mocker.patch.object(
rom_thing,
"_initial_moves_for_z_prediction",
side_effect=add_fake_initial_positions,
)
mock_big_moves = mocker.patch.object(
rom_thing,
"_big_z_corrected_movement",
side_effect=update_stage_pos_on_big_move(),
)
mock_move_check = mocker.patch.object(
rom_thing, "_stage_still_moves", side_effect=[True] * 9 + [False]
)
mock_move_back = mocker.patch.object(
rom_thing, "_move_back_until_motion_detected", side_effect=set_final_pos
)
# Remove the mock RomData before starting
rom_thing._rom_data = stage_measure.RomDataTracker()
# Run function
rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps)
# Check the call counts are as expected
# One call of initial moves
assert mock_init_moves.call_count == 1
# Big moves and the check the stage is moving are each called 10 times as the mock
# for _stage_still_moves replies False on the 10th call.
assert mock_big_moves.call_count == 10
assert mock_move_check.call_count == 10
# And one call of _move_back_until_motion_detected
assert mock_move_back.call_count == 1
assert rom_thing._rom_data.stage_coords == [
{"x": "mock", "y": "starting", "z": "pos"},
"mock-init-pos1",
"mock-init-pos2",
"mock-init-pos3",
"mock-init-pos4",
"mock-init-pos5",
"mocked-big-move-pos1",
"mocked-big-move-pos2",
"mocked-big-move-pos3",
"mocked-big-move-pos4",
"mocked-big-move-pos5",
"mocked-big-move-pos6",
"mocked-big-move-pos7",
"mocked-big-move-pos8",
"mocked-big-move-pos9",
"mock-final-pos", # This overwrites the 10th big move position recording.
]
def test_perform_rom_test_locked(rom_thing, mock_rom_deps):
"""Check the error if the Rom test is locked."""
# Not an RLock so no need to thread.
rom_thing._lock.acquire()
err_msg = "Trying to run ROM test when a test is already running."
with pytest.raises(RuntimeError, match=err_msg):
rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps))
def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
"""Check that perform Rom Test runs the expected high level algorithm."""
def check_and_modify_rom_data(axis: str, direction: int, **_kwargs):
"""Check the rom_data is empty each time, and add a final position."""
# check rom_data was cleared at the start of this run
assert len(rom_thing._rom_data.stage_coords) == 0
dist = 1111 if axis == "x" else 2222
final_pos = {"x": 0, "y": 0}
final_pos[axis] = dist * direction
rom_thing._rom_data.stage_coords.append(final_pos)
mock_move_until_edge = mocker.patch.object(
rom_thing, "_move_until_edge", side_effect=check_and_modify_rom_data
)
mocker.patch.object(
mock_rom_deps.cam, "grab_as_array", return_value=np.zeros([123, 456, 3])
)
final_dict = rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps))
# This should take less than 1 sec
assert final_dict["Time"] <= 1
# CSM should be 2x2 list
assert isinstance(final_dict["CSM Matrix"], list)
assert len(final_dict["CSM Matrix"]) == 2
assert len(final_dict["CSM Matrix"][0]) == 2
assert len(final_dict["CSM Matrix"][1]) == 2
# And step range should be [2222, 4444]
assert final_dict["Step Range"] == [2222, 4444]
# Check that the axes were called in the expected order.
assert mock_move_until_edge.call_count == 4
assert mock_move_until_edge.call_args_list[0].kwargs["axis"] == "x"
assert mock_move_until_edge.call_args_list[0].kwargs["direction"] == 1
assert mock_move_until_edge.call_args_list[1].kwargs["axis"] == "x"
assert mock_move_until_edge.call_args_list[1].kwargs["direction"] == -1
assert mock_move_until_edge.call_args_list[2].kwargs["axis"] == "y"
assert mock_move_until_edge.call_args_list[2].kwargs["direction"] == 1
assert mock_move_until_edge.call_args_list[3].kwargs["axis"] == "y"
assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1