Merge branch 'recentre-with-rom-methods' into 'v3'

Recentre using RangeOfMotionThing methods

Closes #499 and #573

See merge request openflexure/openflexure-microscope-server!429
This commit is contained in:
Julian Stirling 2025-11-11 17:49:08 +00:00
commit 3ecc41720d
4 changed files with 449 additions and 67 deletions

View file

@ -12,6 +12,10 @@ import pytest
import labthings_fastapi as lt
from openflexure_microscope_server.things import stage_measure
from openflexure_microscope_server.things.camera_stage_mapping import (
csm_img_to_stage,
csm_stage_to_img,
)
LOGGER = logging.getLogger("mock-invocation_logger")
@ -56,10 +60,10 @@ def example_rom_data():
mock_positions = [
{"x": 0, "y": 0, "z": 42},
{"x": 727, "y": 2, "z": 154},
{"x": 1454, "y": 4, "z": 228},
{"x": 1454, "y": 4, "z": 248},
{"x": 2181, "y": 6, "z": 351},
{"x": 2908, "y": 8, "z": 509},
{"x": 3635, "y": 10, "z": 617},
{"x": 2908, "y": 8, "z": 430},
{"x": 3635, "y": 10, "z": 490},
]
rom_data = stage_measure.RomDataTracker()
@ -75,12 +79,19 @@ def test_predict_z(example_rom_data):
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},
stage_position={"x": 3635, "y": 10, "z": 490},
)
expected_z_diff = 1343
expected_z_diff = 153
assert mock_z_diff == expected_z_diff
def test_find_tuning_point(example_rom_data):
"""Check that the prediction for the tuning point and z position."""
mock_turn = example_rom_data.find_turning_point(axis="x")
expected_turning = {"x": 7580, "y": 10, "z": 661}
assert mock_turn == expected_turning
@pytest.mark.parametrize(
("movement", "axis", "other_axis"),
[
@ -156,10 +167,13 @@ def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing:
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]:
def apply_csm(x: float, y: float, **_kwargs: 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])}
return csm_img_to_stage(csm_matrix, x=x, y=y)
def un_apply_csm(x: float, y: float, **_kwargs: float) -> dict[str, float]:
"""Convert stage coordinates to image coordinates."""
return csm_stage_to_img(csm_matrix, x=x, y=y)
mock_cam = mocker.Mock()
mock_cam.image_is_sample.return_value = (True, "Mocked not measured.")
@ -168,6 +182,7 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
# 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
mock_csm.convert_stage_to_image_coordinates.side_effect = un_apply_csm
return stage_measure.RomDeps(
autofocus=mocker.Mock(),
@ -178,6 +193,53 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps:
)
@pytest.mark.parametrize(
("pos", "target_pos", "axis", "expected_dir"),
[
({"x": 5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", -1),
({"x": -5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", 1),
({"x": 0, "y": 0, "z": 0}, {"x": 5000, "y": 0, "z": 0}, "x", 1),
# Y is flipped due to CSM sign
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 1),
],
)
def test_img_dir_from_stage_coords(
pos, target_pos, axis, expected_dir, rom_thing, mock_rom_deps
):
"""Check image direction is correctly generated."""
mock_rom_deps.stage.position = pos
direction = rom_thing._img_dir_from_stage_coords(target_pos, axis, mock_rom_deps)
assert direction == expected_dir
def test_distance_in_img_percentage_err(rom_thing, mock_rom_deps):
"""Check that _distance_in_img_percentage throws error if stream res is not set."""
rom_thing._stream_resolution = None
with pytest.raises(RuntimeError):
rom_thing._distance_in_img_percentage(
{"x": 0, "y": 0, "z": 0}, "x", mock_rom_deps
)
@pytest.mark.parametrize(
("pos", "target_pos", "axis", "expected_perc"),
[
({"x": 5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", -352.44),
({"x": -5000, "y": 0, "z": 0}, {"x": 0, "y": 0, "z": 0}, "x", 352.44),
({"x": 0, "y": 0, "z": 0}, {"x": 5000, "y": 0, "z": 0}, "x", 352.44),
# Y is flipped due to CSM sign
({"x": 0, "y": 5000, "z": 0}, {"x": 0, "y": 0, "z": 0}, "y", 462.131),
],
)
def test_distance_in_img_percentage(
pos, target_pos, axis, expected_perc, rom_thing, mock_rom_deps
):
"""Check _distance_in_img_percentage calculates expected values based on mock CSM."""
mock_rom_deps.stage.position = pos
img_perc = rom_thing._distance_in_img_percentage(target_pos, axis, mock_rom_deps)
assert round(img_perc, 3) == expected_perc
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
@ -402,7 +464,7 @@ def test_big_z_corrected_movement(rom_thing, mock_rom_deps):
assert "x" not in move_kwargs
assert "y" not in move_kwargs
assert "z" in move_kwargs
assert move_kwargs["z"] == 1148
assert move_kwargs["z"] == 160
# And one move in image coordinates
assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1
@ -410,7 +472,7 @@ def test_big_z_corrected_movement(rom_thing, mock_rom_deps):
assert lat_mov_kwargs == expected_movement
def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
def test_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
@ -428,7 +490,7 @@ def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker):
rom_thing._rom_data = stage_measure.RomDataTracker()
# Run it!
rom_thing._initial_moves_for_z_prediction("x", direction=-1, rom_deps=mock_rom_deps)
rom_thing._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)]
@ -450,7 +512,7 @@ def test_move_until_edge_error(rom_thing, mock_rom_deps, mocker):
return_value=mock_position_dict
)
mocker.patch.object(
rom_thing, "_initial_moves_for_z_prediction", side_effect=RuntimeError("Mock")
rom_thing, "_moves_for_z_prediction", side_effect=RuntimeError("Mock")
)
# Remove the mock RomData before starting
@ -493,7 +555,7 @@ def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
# Mock the main movement functions
mock_init_moves = mocker.patch.object(
rom_thing,
"_initial_moves_for_z_prediction",
"_moves_for_z_prediction",
side_effect=add_fake_initial_positions,
)
mock_big_moves = mocker.patch.object(
@ -544,13 +606,16 @@ def test_move_until_edge(rom_thing, mock_rom_deps, mocker):
]
def test_perform_rom_test_locked(rom_thing, mock_rom_deps):
"""Check the error if the Rom test is locked."""
def test_perform_rom_actions_locked(rom_thing, mock_rom_deps):
"""Check the error if running one of the calibration actions while 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))
err_msg = "Trying to run recentre when a test is already running."
with pytest.raises(RuntimeError, match=err_msg):
rom_thing.perform_recentre(**dataclasses.asdict(mock_rom_deps))
def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
@ -598,3 +663,111 @@ def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker):
assert mock_move_until_edge.call_args_list[3].kwargs["axis"] == "y"
assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1
def test_perform_recenter(rom_thing, mock_rom_deps, mocker, caplog):
"""Check that performing the recentre runs through expected operations."""
def check_lock(*_args, **_kwargs):
"""Check the thing is locked."""
assert not rom_thing._lock.acquire(blocking=False)
mock_set_stream_res = mocker.patch.object(
rom_thing, "_set_stream_resolution", side_effect=check_lock
)
mock_recentre_axis = mocker.patch.object(rom_thing, "_recentre_axis")
# Set a mock stage position, as we patch _recentre_axis this should be logged
# as the centre.
mock_rom_deps.stage.position = {"x": 4321, "y": 1234, "z": 0}
# Unpack the dict and save because it the dictionary is a copy so we can't check
# mocks from `mock_rom_deps`
mock_deps_dict = dataclasses.asdict(mock_rom_deps)
with caplog.at_level(logging.INFO):
rom_thing.perform_recentre(**mock_deps_dict)
assert len(caplog.messages) == 3
assert caplog.messages[0] == "Recentring the stage."
assert caplog.messages[1] == "Centre is estimated at (4321, 1234)."
assert caplog.messages[2] == "Position reset to (0, 0, 0)."
# Check the lock was freed
assert rom_thing._lock.acquire(blocking=False)
rom_thing._lock.release()
assert mock_set_stream_res.call_count == 1
# Recentre called first in x then in y
assert mock_recentre_axis.call_count == 2
assert mock_recentre_axis.call_args_list[0].args[0] == "x"
assert mock_recentre_axis.call_args_list[1].args[0] == "y"
# check that the position set to zero once
assert mock_deps_dict["stage"].set_zero_position.call_count == 1
@pytest.mark.parametrize("true_on", [1, 4, 10, 11])
def test_recentre_axis(true_on, rom_thing, mock_rom_deps, mocker):
"""Test the high level algorithm of recentring an axis.
This doesn't include deciding if we are centred or choosing the direction to move.
"""
## Start such that movement starts negative
mock_rom_deps.stage.position = {"x": 10000, "y": 10000, "z": 0}
mock_moves = mocker.patch.object(rom_thing, "_moves_for_z_prediction")
# Mock _recentre_decision so it returns (False, 1) a number of times then finally
# (True, 1).
decisions = [(False, 1)] * (true_on - 1) + [(True, 1)]
mock_recentre_decision = mocker.patch.object(
rom_thing, "_recentre_decision", side_effect=decisions
)
if true_on > 10:
with pytest.raises(RuntimeError, match="Couldn't find centre"):
rom_thing._recentre_axis("x", mock_rom_deps)
else:
rom_thing._recentre_axis("x", mock_rom_deps)
# _recentre_decision is called until True or exits after 10 attempts
assert mock_recentre_decision.call_count == min(true_on, 10)
# The _moves_for_z_prediction is called once before any decision, and not called
# after a decision of centred. The max call count is 10
assert mock_moves.call_count == min(true_on, 10)
for i, arg_list in enumerate(mock_moves.call_args_list):
# First direction is -ve due to starting pos, then it changes direction
# as the mock always returns 1
expected_dir = -1 if i < 1 else 1
assert arg_list.kwargs["direction"] == expected_dir
@pytest.mark.parametrize(
("dist", "direction", "expected_decision"),
[
(500, 1, (False, 1)), # Big step is 200% this is over, movement is positive.
(-500, -1, (False, -1)),
(200, 1, (False, 1)),
(199, 1, (True, 1)), # Less than 200% FOV movement return centred=True
(-199, -1, (True, 1)), # Always return 1 when c
],
)
def test_recentre_decision(
dist, direction, expected_decision, rom_thing, mock_rom_deps, mocker
):
"""What the algorithm decides to do in different situations."""
# Mock find turning point just because there is no _rom_data
mocker.patch.object(rom_thing._rom_data, "find_turning_point")
# As _distance_in_img_percentage and _img_dir_from_stage_coords are tested
# this test mocks their values and checks the return is as expected
mocker.patch.object(rom_thing, "_distance_in_img_percentage", return_value=dist)
mocker.patch.object(rom_thing, "_img_dir_from_stage_coords", return_value=direction)
assert rom_thing._recentre_decision("x", mock_rom_deps) == expected_decision
# Check that we make an absolute move (to the centre) before returning centred.
centred = expected_decision[0]
mock_rom_deps.stage.move_absolute.call_count == 1 if centred else 0