Merge branch 'calibration_thing' into 'v3'

Calibrate settling times after moves

See merge request openflexure/openflexure-microscope-server!207
This commit is contained in:
Julian Stirling 2026-05-11 12:32:30 +00:00
commit 92619e2131
3 changed files with 72 additions and 5 deletions

View file

@ -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."""

View file

@ -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"])

View file

@ -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)