From 03dc0a065f6efcc8442db402d6e05d5cc7a6c708 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 14 May 2026 11:14:40 +0100 Subject: [PATCH 1/4] Save useful ROM data to data directory --- .../things/stage_measure.py | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 4d426a51..6d33fbfb 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -13,10 +13,13 @@ is tracked and an error is raised if it exceeds a minimum amount. Currently this is 10% of the expected motion is the measured axis. """ +import json +import os import time from copy import copy +from datetime import datetime from threading import Lock -from typing import Any, Literal, Mapping, Optional, overload +from typing import Any, Literal, Mapping, Optional, Self, overload import numpy as np @@ -24,6 +27,8 @@ import labthings_fastapi as lt from camera_stage_mapping import fft_image_tracking # Things +from openflexure_microscope_server.things import OFMThing + from .autofocus import AutofocusThing from .camera import BaseCamera from .camera_stage_mapping import CameraStageMapper @@ -114,7 +119,7 @@ class ParasiticMotionError(lt.exceptions.InvocationError): """ -class RangeofMotionThing(lt.Thing): +class RangeofMotionThing(OFMThing): """A class used to measure the range of motion of the stage in X and Y.""" _class_settings = {"validate_properties_on_set": True} @@ -128,6 +133,14 @@ class RangeofMotionThing(lt.Thing): default=None, readonly=True ) + csm_matrix: Optional[list[list[float]]] = lt.setting(default=None, readonly=True) + + stage_coords: Optional[Any] = lt.setting(default=None, readonly=True) + + correlations: Optional[Any] = lt.setting(default=None, readonly=True) + + time: Optional[float] = lt.setting(default=None, readonly=True) + def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: """Initialise and create the lock.""" super().__init__(thing_server_interface) @@ -135,6 +148,11 @@ class RangeofMotionThing(lt.Thing): self._stream_resolution: Optional[tuple[int, int]] = None self._rom_data = RomDataTracker() + def __enter__(self) -> Self: + """Open hardware connection when the Thing context manager is opened.""" + super().__enter__() + return self + @lt.action def perform_rom_test(self) -> Mapping[str, Any]: """Measures the range of motion of the stage across the x and y axes. @@ -162,6 +180,9 @@ class RangeofMotionThing(lt.Thing): axes: tuple[Literal["x"], Literal["y"]] = ("x", "y") directions: tuple[Literal[1], Literal[-1]] = (1, -1) + stage_dic = {} + cor_dic = {} + for axis in axes: # The final position of the axis under test in the given direction axis_limits = [] @@ -171,14 +192,37 @@ class RangeofMotionThing(lt.Thing): self._move_until_edge(axis=axis, direction=axis_dir) # Append final position from tracker axis_limits.append(self._rom_data.final_position[axis]) + axis_string = "positive" if axis_dir == 1 else "negative" + stage_dic[f"{axis}_{axis_string}"] = self._rom_data.stage_coords + cor_dic[f"{axis}_{axis_string}"] = self._rom_data.offsets # Calculate step range. step_range.append(abs(axis_limits[0] - axis_limits[1])) - total_time = time.time() - start_time + total_time = (time.time() - start_time) / 60 self.logger.info( f"Range of motion is {step_range[0]} x {step_range[1]} steps" ) - self.calibrated_range = (step_range[0], step_range[1]) + data = { + "calibrated_range": [step_range[0], step_range[1]], + "stage_coords": self.stage_coords, + "correlations": self.correlations, + "csm_matrix": self._csm.image_to_stage_displacement_matrix, + "time": self.time, + } + + if not os.path.exists(self.data_dir): + os.makedirs(self.data_dir) + + with open( + os.path.join( + self.data_dir, + f"rom_data_{datetime.now().strftime('%Y_%m_%d_%H_%M')}.json", + ), + "w", + encoding="utf-8", + ) as f: + json.dump(data, f, indent=4) + finally: self._lock.release() return { From 5c128c14679624e83e5db8ec4635bf06a53bf2c6 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 14 May 2026 15:58:26 +0100 Subject: [PATCH 2/4] Removed settings, fixed saving issue and refactored --- .../things/stage_measure.py | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 6d33fbfb..c9422072 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -19,7 +19,7 @@ import time from copy import copy from datetime import datetime from threading import Lock -from typing import Any, Literal, Mapping, Optional, Self, overload +from typing import Any, Literal, Mapping, Optional, overload import numpy as np @@ -133,14 +133,6 @@ class RangeofMotionThing(OFMThing): default=None, readonly=True ) - csm_matrix: Optional[list[list[float]]] = lt.setting(default=None, readonly=True) - - stage_coords: Optional[Any] = lt.setting(default=None, readonly=True) - - correlations: Optional[Any] = lt.setting(default=None, readonly=True) - - time: Optional[float] = lt.setting(default=None, readonly=True) - def __init__(self, thing_server_interface: lt.ThingServerInterface) -> None: """Initialise and create the lock.""" super().__init__(thing_server_interface) @@ -148,11 +140,6 @@ class RangeofMotionThing(OFMThing): self._stream_resolution: Optional[tuple[int, int]] = None self._rom_data = RomDataTracker() - def __enter__(self) -> Self: - """Open hardware connection when the Thing context manager is opened.""" - super().__enter__() - return self - @lt.action def perform_rom_test(self) -> Mapping[str, Any]: """Measures the range of motion of the stage across the x and y axes. @@ -204,20 +191,19 @@ class RangeofMotionThing(OFMThing): ) data = { "calibrated_range": [step_range[0], step_range[1]], - "stage_coords": self.stage_coords, - "correlations": self.correlations, + "stage_coords": stage_dic, + "correlations": cor_dic, "csm_matrix": self._csm.image_to_stage_displacement_matrix, - "time": self.time, + "time": total_time, } if not os.path.exists(self.data_dir): os.makedirs(self.data_dir) + timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M") + datafile_path = os.path.join(self.data_dir, f"rom_data_{timestamp}.json") with open( - os.path.join( - self.data_dir, - f"rom_data_{datetime.now().strftime('%Y_%m_%d_%H_%M')}.json", - ), + datafile_path, "w", encoding="utf-8", ) as f: From 714435f2d429578bc8195c8e4c4534af8fcef690 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 14 May 2026 16:49:29 +0100 Subject: [PATCH 3/4] Fix unit tests for stage_measrue as an OFMThing --- .../things/__init__.py | 14 ++++++++++++++ .../things/stage_measure.py | 6 +----- tests/unit_tests/test_stage_measure.py | 13 ++++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/__init__.py b/src/openflexure_microscope_server/things/__init__.py index c497209f..255e0379 100644 --- a/src/openflexure_microscope_server/things/__init__.py +++ b/src/openflexure_microscope_server/things/__init__.py @@ -5,6 +5,7 @@ with other Things and including them in the LabThings-FastAPI config file. """ import os +from types import TracebackType from typing import Optional, Self import labthings_fastapi as lt @@ -35,6 +36,19 @@ class OFMThing(lt.Thing): ) return self + def __exit__( + self, + _exc_type: type[BaseException], + _exc_value: Optional[BaseException], + _traceback: Optional[TracebackType], + ) -> None: + """Close the OFMThing. + + This is needed for the context manager protocol to work. Currently it doesn't + do anything. + """ + pass + @property def data_dir(self) -> str: """The data directory for this thing.""" diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c9422072..cedfaa0f 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -202,11 +202,7 @@ class RangeofMotionThing(OFMThing): timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M") datafile_path = os.path.join(self.data_dir, f"rom_data_{timestamp}.json") - with open( - datafile_path, - "w", - encoding="utf-8", - ) as f: + with open(datafile_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=4) finally: diff --git a/tests/unit_tests/test_stage_measure.py b/tests/unit_tests/test_stage_measure.py index fad2df21..9204878f 100644 --- a/tests/unit_tests/test_stage_measure.py +++ b/tests/unit_tests/test_stage_measure.py @@ -1,6 +1,7 @@ """File contains unit tests for stage_measure.""" import logging +import tempfile from copy import copy import numpy as np @@ -148,11 +149,14 @@ def test_error_if_no_stream_res_set_when_requesting_img_coords(): @pytest.fixture -def rom_thing(example_rom_data, csm_matrix) -> stage_measure.RangeofMotionThing: +def rom_thing(example_rom_data, csm_matrix, mocker) -> stage_measure.RangeofMotionThing: """Yield a RangeofMotionThing already populated with some example rom_data.""" rom_thing = create_thing_without_server( stage_measure.RangeofMotionThing, mock_all_slots=True ) + type(rom_thing._thing_server_interface).application_config = mocker.PropertyMock( + return_value={"data_folder": tempfile.gettempdir()} + ) rom_thing._stream_resolution = [800, 600] rom_thing._rom_data = example_rom_data @@ -586,7 +590,7 @@ def test_perform_rom_actions_locked(rom_thing): rom_thing.perform_recentre() -def test_perform_rom_test_(rom_thing, mocker): +def test_perform_rom_test(rom_thing, mocker): """Check that perform Rom Test runs the expected high level algorithm.""" def check_and_modify_rom_data(axis: str, direction: int, **_kwargs): @@ -605,7 +609,10 @@ def test_perform_rom_test_(rom_thing, mocker): mocker.patch.object( rom_thing._cam, "grab_as_array", return_value=np.zeros([123, 456, 3]) ) - final_dict = rom_thing.perform_rom_test() + + # Enter the ROM thing to get the data directory. + with rom_thing as running_rom_thing: + final_dict = running_rom_thing.perform_rom_test() # This should take less than 1 sec assert final_dict["Time"] <= 1 From 94a3fead9ce9d328d81eb9ba68089dcc10cb120f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 15 May 2026 13:45:46 +0000 Subject: [PATCH 4/4] Apply suggestions from code review of branch save-rom-data --- src/openflexure_microscope_server/things/stage_measure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index cedfaa0f..0f633ee1 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -185,7 +185,7 @@ class RangeofMotionThing(OFMThing): # Calculate step range. step_range.append(abs(axis_limits[0] - axis_limits[1])) - total_time = (time.time() - start_time) / 60 + total_time = time.time() - start_time self.logger.info( f"Range of motion is {step_range[0]} x {step_range[1]} steps" )