From 500e08555cea6cbd4282621b21db0a9df2f93000 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Wed, 1 Apr 2026 18:45:16 +0100 Subject: [PATCH 1/4] Measure settling time action and hold in sharpness monitor --- .../things/autofocus.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 00cd7a4d..0093a2de 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -345,6 +345,10 @@ class JPEGSharpnessMonitor: final_z_position: int = self.stage_positions[-1]["z"] return data_index, final_z_position + def hold(self, delay: float) -> None: + """Continue to monitor sharpness without moving for delay seconds.""" + time.sleep(delay) + def move_data( self, istart: int, istop: Optional[int] = None ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -836,6 +840,18 @@ 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) -> dict: + """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) + sharpness_monitor.hold(delay) + return sharpness_monitor.data_dict() + class NotAPeakError(lt.exceptions.InvocationError): """The data to fit isn't a peak.""" From ffa023dfa91a79f4fcfd1f7925d8ae4b91e902db Mon Sep 17 00:00:00 2001 From: jaknapper Date: Thu, 9 Apr 2026 12:01:57 +0100 Subject: [PATCH 2/4] Correct data types in sharpness monitors --- .../things/autofocus.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 0093a2de..f426f152 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -389,13 +389,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. @@ -436,7 +440,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( @@ -461,7 +465,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( @@ -841,7 +845,9 @@ class AutofocusThing(lt.Thing): return final_z, z_positions @lt.action - def measure_settling_time(self, delay: float = 2, dz: int = 800) -> dict: + 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 @@ -850,7 +856,7 @@ class AutofocusThing(lt.Thing): sharpness_monitor.focus_rel(-dz) sharpness_monitor.focus_rel(dz) sharpness_monitor.hold(delay) - return sharpness_monitor.data_dict() + return sharpness_monitor.data_to_array() class NotAPeakError(lt.exceptions.InvocationError): From c171e5bddec79074144890b42c7204fdd609f766 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 11 May 2026 12:49:20 +0100 Subject: [PATCH 3/4] Remove hold from sharpness monitor --- src/openflexure_microscope_server/things/autofocus.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index f426f152..eebff335 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -345,10 +345,6 @@ class JPEGSharpnessMonitor: final_z_position: int = self.stage_positions[-1]["z"] return data_index, final_z_position - def hold(self, delay: float) -> None: - """Continue to monitor sharpness without moving for delay seconds.""" - time.sleep(delay) - def move_data( self, istart: int, istop: Optional[int] = None ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -855,7 +851,8 @@ class AutofocusThing(lt.Thing): with JPEGSharpnessMonitor(self._stage, self._cam) as sharpness_monitor: sharpness_monitor.focus_rel(-dz) sharpness_monitor.focus_rel(dz) - sharpness_monitor.hold(delay) + # Sleep for the given delay, continuing to measure frames without moving + time.sleep(delay) return sharpness_monitor.data_to_array() From cc6866b2e59c51404cdc9a328f0613be8f326ad6 Mon Sep 17 00:00:00 2001 From: jaknapper Date: Mon, 11 May 2026 12:49:52 +0100 Subject: [PATCH 4/4] Add tests for measure settling time --- tests/integration_tests/test_actions.py | 48 +++++++++++++++++++++++++ tests/unit_tests/test_stage_measure.py | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) 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)