From 664c7de5f83b2a780e35c565e87d8f5563d536c4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 00:01:03 +0100 Subject: [PATCH] Add more ROM tests --- .../things/stage_measure.py | 18 +-- tests/test_stage_measure.py | 105 ++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index dee255b5..518f64a8 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -380,9 +380,8 @@ class RangeofMotionThing(lt.Thing): Delta is updated and tracked after each move. - :param stream_resolution: The resolution of the stream from the camera. - :param direction: The direction the stage moves. :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param direction: The direction the stage moves. :param rom_deps: All dependencies that were passed to the calling Action """ movement = self._movement_in_img_coords( @@ -428,16 +427,19 @@ class RangeofMotionThing(lt.Thing): this method will move in the opposite direction. :param rom_deps: All dependencies that were passed to the calling Action """ + + def _reverse_move_dict(displacement: float) -> dict[str, float]: + move = {"x": 0, "y": 0} + move[axis] = displacement * direction * -1 + return move + # Array of increasing pixel sizes (powers of 2 from 1 to 512) - displacements = [2**i for i in range(10)] + movements = [_reverse_move_dict(2**i) for i in range(10)] motion_minimum = 20 # minimum number of pixels for motion to be detected - movement = {"x": 0, "y": 0} - - for displacement in displacements: + for movement in movements: # Increment movement - movement[axis] = displacement * direction * -1 rom_deps.logger.info(f"Testing with step size {movement[axis]}") offset = self._move_and_measure( @@ -463,7 +465,7 @@ class RangeofMotionThing(lt.Thing): :param movement: A dictionary containing the distance to move in image coords. :param rom_deps: All dependencies that were passed to the calling Action - :param perform_autofocus: Set to False to disable atutofocus after move. + :param perform_autofocus: Set to False to disable autofocus after move. Default is True :param max_autofocus_repeats: The number of times to repeat the focus if the detected (on-axis) offset is below ``abs_min_offset``. This will only work diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 3f5d375b..796748ac 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -114,3 +114,108 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker): # 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", side_effect=(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_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. + """ + + def gen_offset(*_args, **_kwargs): + return {"x": 0, "y": 0} + + mock_move_n_meas = mocker.patch.object( + rom_thing, "_move_and_measure", side_effect=gen_offset + ) + + with pytest.raises(RuntimeError, match="Cannot detect motion again"): + rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps) + assert mock_move_n_meas.call_count == 10 + for i, call_args in enumerate(mock_move_n_meas.call_args_list): + call_args.kwargs["movement"] = {"x": 0, "y": 2**i} + 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": 21, "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": -(2**i), "y": 0} + call_args.kwargs["perform_autofocus"] = False