Update move_and_measure, add unit tests

This commit is contained in:
jaknapper 2026-05-13 12:18:36 +01:00
parent e93d9777a2
commit 563bfcf7be
2 changed files with 170 additions and 4 deletions

View file

@ -536,20 +536,30 @@ class AutofocusThing(lt.Thing):
self, self,
dz: Sequence[int], dz: Sequence[int],
wait: float = 0, wait: float = 0,
sharpness_metric: SharpnessMethod = SharpnessMethod.JPEG,
record: Optional[int] = None,
) -> SharpnessDataArrays: ) -> SharpnessDataArrays:
"""Make a move (or a series of moves) and monitor sharpness. """Make a move (or a series of moves) and monitor sharpness.
This method will will make a series of relative moves in z, and This method will will make a series of relative moves in z, and
return the sharpness (JPEG size and FOM) vs time, along with timestamps return the sharpness (JPEG size and/or FOM) vs time, along with timestamps
for the moves. This can be used to calibrate autofocus. for the moves. This can be used to calibrate autofocus.
Each move is relative to the last one, i.e. we will finish at Each move is relative to the last one, i.e. we will finish at
``sum(dz)`` relative to the starting position. ``sum(dz)`` relative to the starting position.
If ``wait`` is specified, we will wait for that many seconds :param dz: A list of moves to make in z (in steps).
between moves. :param wait: The time to pause between movements, in seconds.
:param sharpness_metric: Sharpness metric used for evaluation. Either
JPEG file size (1) or inbuilt figure of metric (2).
:param record: Optional bitmask of sharpness metrics to record during
acquisition. Sharpness metrics can be selected as the sum of their int
value (see sharpness_metric).
If None, defaults to recording only the selected sharpness_metric.
""" """
with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: with JPEGSharpnessMonitor(
self._stage, self._cam, sharpness_metric, record
) as sharpness_monitor:
for move_index, current_dz in enumerate(dz): for move_index, current_dz in enumerate(dz):
if move_index > 0 and wait > 0: if move_index > 0 and wait > 0:
time.sleep(wait) time.sleep(wait)

View file

@ -10,7 +10,9 @@ from labthings_fastapi.testing import create_thing_without_server
from openflexure_microscope_server.things.autofocus import ( from openflexure_microscope_server.things.autofocus import (
AutofocusThing, AutofocusThing,
JPEGSharpnessMonitor,
NoFocusFoundError, NoFocusFoundError,
SharpnessMethod,
) )
@ -98,3 +100,157 @@ def test_looping_autofocus(start_z, max_loc, centre, attempts_expected, passes,
) )
assert sharpness_monitor.focus_rel.call_count == attempts_expected assert sharpness_monitor.focus_rel.call_count == attempts_expected
assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40 assert abs(max_loc - autofocus_thing._stage.position["z"]) < dz / 40
@pytest.fixture
def mock_camera(mocker):
"""Mock a camera to return fom properties."""
camera = mocker.MagicMock()
camera.supports_focus_fom = True
camera.focus_fom = 123
return camera
@pytest.fixture
def mock_stage(mocker):
"""Mock a stage to return position."""
stage = mocker.MagicMock()
stage.position = {"x": 0, "y": 0, "z": 0}
return stage
def test_record_defaults_to_method(mock_stage, mock_camera):
"""If record=None, only the selected method is recorded."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.FOCUS_FOM,
)
assert monitor.record == SharpnessMethod.FOCUS_FOM
def test_record_must_include_selected_method(mock_stage, mock_camera):
"""The selected autofocus metric must also be recorded."""
with pytest.raises(ValueError, match="not being recorded"):
JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.FOCUS_FOM,
record=SharpnessMethod.JPEG,
)
def test_focus_fom_requires_camera_support(mock_stage, mock_camera):
"""FocusFoM recording requires camera support."""
mock_camera.supports_focus_fom = False
with pytest.raises(ValueError, match="doesn't support"):
JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.FOCUS_FOM,
)
def test_jpeg_sizes_property_requires_recording(mock_stage, mock_camera):
"""JPEG sizes should error if JPEG recording disabled."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.FOCUS_FOM,
)
with pytest.raises(ValueError, match="JPEG sizes are not being recorded"):
_ = monitor.jpeg_sizes
def test_focus_fom_property_requires_recording(mock_stage, mock_camera):
"""FocusFoM values should error if FOM recording disabled."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.JPEG,
)
with pytest.raises(ValueError, match="FocusFoM values are not being recorded"):
_ = monitor.focus_foms
@pytest.mark.parametrize(
("method", "expected"),
[
(SharpnessMethod.JPEG, [100, 200, 300]),
(SharpnessMethod.FOCUS_FOM, [1, 5, 2]),
],
)
def test_move_data_uses_selected_method(
method,
expected,
mock_stage,
mock_camera,
):
"""move_data should select the correct sharpness metric."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=method,
record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM,
)
monitor._jpeg_times = [1.0, 2.0, 3.0]
monitor._jpeg_sizes = [100, 200, 300]
monitor._focus_foms = [1, 5, 2]
monitor._stage_times = [0.5, 3.5]
monitor._stage_positions = [
{"z": 0},
{"z": 100},
]
_, _, sharpnesses = monitor.move_data(0)
assert sharpnesses.tolist() == expected
def test_monitor_records_both_metrics(mock_stage, mock_camera):
"""Both JPEG and FocusFoM metrics can be recorded together."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.JPEG,
record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM,
)
monitor._jpeg_sizes.append(999)
monitor._focus_foms.append(321)
assert monitor.jpeg_sizes == [999]
assert monitor.focus_foms == [321]
def test_move_data_uses_focus_fom(mock_stage, mock_camera):
"""Test that move_data returns the chosen metric."""
monitor = JPEGSharpnessMonitor(
mock_stage,
mock_camera,
method=SharpnessMethod.FOCUS_FOM,
record=SharpnessMethod.JPEG + SharpnessMethod.FOCUS_FOM,
)
# Record both sharpness metrics
monitor._jpeg_times = [1.0, 2.0, 3.0]
monitor._jpeg_sizes = [100, 200, 300]
monitor._focus_foms = [10, 99, 20]
monitor._stage_times = [0.5, 3.5]
monitor._stage_positions = [
{"z": 0},
{"z": 100},
]
# sharpnesses chosen by the chosen method
_, _, sharpnesses = monitor.move_data(0)
# ensure sharpnesses matches focus_foms
assert sharpnesses.tolist() == [10, 99, 20]