diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 00cd7a4d..eebff335 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -385,13 +385,17 @@ class JPEGSharpnessMonitor: ) return jpeg_heights[np.argmax(jpeg_sizes)] - def data_dict(self) -> SharpnessDataArrays: - """Return the gathered data as a single convenient dictionary.""" + def data_to_array(self) -> SharpnessDataArrays: + """Return the gathered data as SharpnessDataArrays.""" data = {} for k in ["jpeg_times", "jpeg_sizes", "stage_times", "stage_positions"]: data[k] = getattr(self, k) return SharpnessDataArrays(**data) + def data_dict(self) -> dict: + """Return the gathered data as dict.""" + return self.data_to_array().model_dump() + class AutofocusThing(lt.Thing): """The Thing concerned with combinations of z axis movements and the camera. @@ -432,7 +436,7 @@ class AutofocusThing(lt.Thing): # Move to the target position fz (relative move of (peak - current z)) sharpness_monitor.focus_rel(peak_z - base_z) # Return all focus data - return sharpness_monitor.data_dict() + return sharpness_monitor.data_to_array() @lt.action def z_move_and_measure_sharpness( @@ -457,7 +461,7 @@ class AutofocusThing(lt.Thing): if move_index > 0 and wait > 0: time.sleep(wait) sharpness_monitor.focus_rel(current_dz) - return sharpness_monitor.data_dict() + return sharpness_monitor.data_to_array() @lt.action def looping_autofocus( @@ -836,6 +840,21 @@ class AutofocusThing(lt.Thing): final_z = self._stage.position["z"] return final_z, z_positions + @lt.action + def measure_settling_time( + self, delay: float = 2, dz: int = 800 + ) -> SharpnessDataArrays: + """Make a Z move down then up by dz, then pause for delay while monitoring sharpness. + + This is useful to see how long to wait for the sharpness value to converge + """ + with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: + sharpness_monitor.focus_rel(-dz) + sharpness_monitor.focus_rel(dz) + # Sleep for the given delay, continuing to measure frames without moving + time.sleep(delay) + return sharpness_monitor.data_to_array() + class NotAPeakError(lt.exceptions.InvocationError): """The data to fit isn't a peak.""" diff --git a/tests/integration_tests/test_actions.py b/tests/integration_tests/test_actions.py index 5872c518..80ec2573 100644 --- a/tests/integration_tests/test_actions.py +++ b/tests/integration_tests/test_actions.py @@ -114,3 +114,51 @@ def test_camera_stage_mapping_calibration(simulation_test_env): assert isinstance(csm_matrix, list) # Check CSM is as expected to an absolute tolerance of 1e-3 assert np.allclose(csm_matrix, expected_matrix, atol=1e-3) + + +def test_measure_settling_time(simulation_test_env): + """Test measure_settling_time captures data while moving and holding.""" + autofocus = simulation_test_env.get_thing_client("autofocus") + stage = simulation_test_env.get_thing_client("stage") + + start_z = stage.position["z"] + + delay = 1 + dz = 1000 + + data = autofocus.measure_settling_time(delay=delay, dz=dz) + + # Check returned arrays contain data + assert len(data["jpeg_times"]) > 0 + assert len(data["jpeg_sizes"]) > 0 + assert len(data["stage_times"]) == 4 + assert len(data["stage_positions"]) == 4 + + # Check the stage moved down then back up + z_positions = [p["z"] for p in data["stage_positions"]] + + assert z_positions[0] == start_z + assert z_positions[1] == start_z - dz + assert z_positions[2] == start_z - dz + assert z_positions[3] == start_z + + # Check final stage position returned to start + assert stage.position["z"] == start_z + + # Check frames continued to be collected after the final move completed + final_move_time = data["stage_times"][-1] + jpeg_times_after_final_move = [t for t in data["jpeg_times"] if t > final_move_time] + + assert len(jpeg_times_after_final_move) > 0 + + +def test_measure_settling_time_hold_duration(simulation_test_env): + """Test measure_settling_time continues capturing during hold.""" + autofocus = simulation_test_env.get_thing_client("autofocus") + + short_data = autofocus.measure_settling_time(delay=0, dz=400) + long_data = autofocus.measure_settling_time(delay=1, dz=400) + + # Longer delay should collect more JPEG frames + assert len(long_data["jpeg_times"]) > len(short_data["jpeg_times"]) + assert len(long_data["jpeg_sizes"]) > len(short_data["jpeg_sizes"]) diff --git a/tests/unit_tests/test_stage_measure.py b/tests/unit_tests/test_stage_measure.py index d4b5c5a8..fad2df21 100644 --- a/tests/unit_tests/test_stage_measure.py +++ b/tests/unit_tests/test_stage_measure.py @@ -247,7 +247,7 @@ def test_offset_from(rom_thing, mocker): def test_move_and_measure(perform_autofocus, rom_thing, 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 + This doesn't test with the repeated 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)