From 5eea78cac766ad0ba095821436c21cfb84f47899 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Sun, 14 Dec 2025 11:52:07 +0000 Subject: [PATCH] update action, property, and setting syntax for labthings-fastapi 0.0.12 --- pyproject.toml | 2 +- .../things/autofocus.py | 22 ++---- .../things/camera/__init__.py | 52 ++++++------- .../things/camera/opencv.py | 6 +- .../things/camera/picamera.py | 75 ++++++++----------- .../things/camera/simulation.py | 24 +++--- .../things/camera_stage_mapping.py | 22 +++--- .../things/smart_scan.py | 55 +++++--------- .../things/stage/__init__.py | 29 +++---- .../things/stage/dummy.py | 8 +- .../things/stage/sangaboard.py | 10 +-- .../things/stage_measure.py | 8 +- .../things/system.py | 14 ++-- tests/test_stage.py | 4 +- 14 files changed, 139 insertions(+), 192 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 34d723be..b285de51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,7 +161,7 @@ ignore = [ [tool.ruff.lint.pydocstyle] # This lets the D401 checker understand that decorated thing properties and thing # settings act like properties so should be documented as such. -property-decorators = ["labthings_fastapi.thing_property", "labthings_fastapi.thing_setting"] +property-decorators = ["labthings_fastapi.property", "labthings_fastapi.setting"] [tool.ruff.lint.pylint] max-args = 7 diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 44098957..ee023ddb 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -350,7 +350,7 @@ class AutofocusThing(lt.Thing): field of view to assess focus (autofocus and testing the success of a z-stack) """ - @lt.thing_action + @lt.action def fast_autofocus( self, sharpness_monitor: SharpnessMonitorDep, @@ -381,7 +381,7 @@ class AutofocusThing(lt.Thing): # Return all focus data return sharpness_monitor.data_dict() - @lt.thing_action + @lt.action def z_move_and_measure_sharpness( self, sharpness_monitor: SharpnessMonitorDep, @@ -407,7 +407,7 @@ class AutofocusThing(lt.Thing): sharpness_monitor.focus_rel(current_dz) return sharpness_monitor.data_dict() - @lt.thing_action + @lt.action def looping_autofocus( self, stage: Stage, @@ -455,19 +455,13 @@ class AutofocusThing(lt.Thing): "Looping autofocus couldn't converge on a focus location." ) - stack_images_to_save = lt.ThingSetting( - initial_value=1, - model=int, - ) + stack_images_to_save: int = lt.setting(default=1) """The number of images to save in a stack. Defaults to 1 unless you need to see either side of focus """ - stack_min_images_to_test = lt.ThingSetting( - initial_value=9, - model=int, - ) + stack_min_images_to_test: int = lt.setting(default=9) """The minimum number of images to capture in a stack. This many images are captures and tested for focus, if the focus is not central @@ -477,7 +471,7 @@ class AutofocusThing(lt.Thing): Defaults to 9 which balances reliability and speed. """ - stack_dz = lt.ThingSetting(initial_value=50, model=int) + stack_dz: int = lt.setting(default=50) """Distance in steps between images in a z-stack. Suggested values: @@ -487,7 +481,7 @@ class AutofocusThing(lt.Thing): * 200 for 20x """ - @lt.thing_action + @lt.action def create_stack_params( self, images_dir: str, @@ -560,7 +554,7 @@ class AutofocusThing(lt.Thing): save_resolution=save_resolution, ) - @lt.thing_action + @lt.action def run_smart_stack( self, cam: CameraClient, diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 9cda7723..70660b21 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -203,7 +203,7 @@ class BaseCamera(lt.Thing): """Close hardware connection when the Thing context manager is closed.""" raise NotImplementedError("CameraThings must define their own __exit__ method") - @lt.thing_property + @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating. @@ -212,7 +212,7 @@ class BaseCamera(lt.Thing): """ return False - @lt.thing_action + @lt.action def start_streaming( self, main_resolution: tuple[int, int], buffer_count: int ) -> None: @@ -239,21 +239,21 @@ class BaseCamera(lt.Thing): self.mjpeg_stream._streaming = False self.lores_mjpeg_stream._streaming = False - @lt.thing_property + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" raise NotImplementedError( "CameraThings must define their own stream_active method" ) - @lt.thing_action + @lt.action def discard_frames(self) -> None: """Discard frames so that the next frame captured is fresh.""" raise NotImplementedError( "CameraThings must define their own discard_frames method" ) - @lt.thing_action + @lt.action def capture_array( self, stream_name: Literal["main", "lores", "raw", "full"] = "main", @@ -264,10 +264,10 @@ class BaseCamera(lt.Thing): "CameraThings must define their own capture_array method" ) - downsampled_array_factor = lt.ThingProperty(int, 2) + downsampled_array_factor: int = lt.property(default=2) """The downsampling factor when calling capture_downsampled_array.""" - @lt.thing_action + @lt.action def capture_downsampled_array(self) -> ArrayModel: """Acquire one image from the camera, downsample, and return as an array. @@ -279,7 +279,7 @@ class BaseCamera(lt.Thing): img = self.capture_array() return downsample(self.downsampled_array_factor, img) - @lt.thing_action + @lt.action def capture_jpeg( self, metadata_getter: lt.deps.GetThingStates, @@ -316,7 +316,7 @@ class BaseCamera(lt.Thing): return JPEGBlob.from_temporary_directory(directory, fname) - @lt.thing_action + @lt.action def grab_jpeg( self, portal: lt.deps.BlockingPortal, @@ -340,7 +340,7 @@ class BaseCamera(lt.Thing): frame = portal.call(stream.grab_frame) return JPEGBlob.from_bytes(frame) - @lt.thing_action + @lt.action def grab_as_array( self, portal: lt.deps.BlockingPortal, @@ -367,7 +367,7 @@ class BaseCamera(lt.Thing): tries += 1 raise OSError("Could not open frames from MJPEG stream.") - @lt.thing_action + @lt.action def grab_jpeg_size( self, portal: lt.deps.BlockingPortal, @@ -389,7 +389,7 @@ class BaseCamera(lt.Thing): "CameraThings must define their own capture_image method" ) - @lt.thing_action + @lt.action def capture_and_save( self, jpeg_path: str, @@ -420,7 +420,7 @@ class BaseCamera(lt.Thing): save_resolution, ) - @lt.thing_action + @lt.action def capture_to_memory( self, logger: lt.deps.InvocationLogger, @@ -444,7 +444,7 @@ class BaseCamera(lt.Thing): image, metadata = self._robust_image_capture(metadata_getter, logger) return self._memory_buffer.add_image(image, metadata, buffer_max=buffer_max) - @lt.thing_action + @lt.action def save_from_memory( self, jpeg_path: str, @@ -473,7 +473,7 @@ class BaseCamera(lt.Thing): save_resolution=save_resolution, ) - @lt.thing_action + @lt.action def clear_buffers(self) -> None: """Clear all images in memory.""" self._memory_buffer.clear() @@ -600,10 +600,10 @@ class BaseCamera(lt.Thing): except Exception as e: raise IOError(f"An error occurred while saving {jpeg_path}") from e - settling_time = lt.ThingSetting(float, 0.2) + settling_time: float = lt.setting(default=0.2) """The settling time when calling the ``settle()`` method.""" - @lt.thing_action + @lt.action def settle(self) -> None: """Sleep for the settling time, ready to provide a fresh frame. @@ -616,24 +616,24 @@ class BaseCamera(lt.Thing): time.sleep(self.settling_time) self.discard_frames() - @lt.thing_property + @lt.property def primary_calibration_actions(self) -> list[ActionButton]: """The calibration actions for both calibration wizard and settings panel.""" return [] - @lt.thing_property + @lt.property def secondary_calibration_actions(self) -> list[ActionButton]: """The calibration actions that appear only in settings panel.""" return [] - @lt.thing_property + @lt.property def manual_camera_settings(self) -> list[PropertyControl]: """The camera settings to expose as property controls in the settings panel.""" return [] # Note that the default detector name is set at init. This is over written if # setting is loaded from disk. - @lt.thing_setting + @lt.setting def detector_name(self) -> str: """The name of the active background selector.""" return self._detector_name @@ -650,12 +650,12 @@ class BaseCamera(lt.Thing): """The active background detector instance.""" return self.background_detectors[self.detector_name] - @lt.thing_property + @lt.property def background_detector_status(self) -> BackgroundDetectorStatus: """The status of the active detector for the UI.""" return self.active_detector.status - @lt.thing_action + @lt.action def update_detector_settings(self, data: dict[str, Any]) -> None: """Update the settings of the current detector. @@ -671,7 +671,7 @@ class BaseCamera(lt.Thing): # Manually save settings as the setter is not called. self.save_settings() - @lt.thing_setting + @lt.setting def background_detector_data(self) -> dict: """The data for each background detector, used to save to disk.""" data = {} @@ -704,13 +704,13 @@ class BaseCamera(lt.Thing): f"No background detector named {name}, settings will be discarded." ) - @lt.thing_action + @lt.action def image_is_sample(self, portal: lt.deps.BlockingPortal) -> tuple[bool, str]: """Label the current image as either background or sample.""" current_image = self.grab_as_array(portal, stream_name="lores") return self.active_detector.image_is_sample(current_image) - @lt.thing_action + @lt.action def set_background(self, portal: lt.deps.BlockingPortal) -> None: """Grab an image, and use its statistics to set the background. diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index 644b4cfa..421b3a6e 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -60,7 +60,7 @@ class OpenCVCamera(BaseCamera): self._capture_thread.join() self.cap.release() - @lt.thing_property + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" if self._capture_enabled and self._capture_thread: @@ -81,12 +81,12 @@ class OpenCVCamera(BaseCamera): ].tobytes() self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) - @lt.thing_action + @lt.action def discard_frames(self) -> None: """Discard frames so that the next frame captured is fresh.""" self.capture_array() - @lt.thing_action + @lt.action def capture_array( self, stream_name: Literal["main", "full"] = "full", diff --git a/src/openflexure_microscope_server/things/camera/picamera.py b/src/openflexure_microscope_server/things/camera/picamera.py index 86b5241a..14a9b723 100644 --- a/src/openflexure_microscope_server/things/camera/picamera.py +++ b/src/openflexure_microscope_server/things/camera/picamera.py @@ -169,30 +169,19 @@ class StreamingPiCamera2(BaseCamera): # trigger a NotConnectedToServerError self._colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning) - stream_resolution = lt.ThingProperty( - tuple[int, int], - initial_value=(820, 616), - ) + stream_resolution: tuple[int, int] = lt.property(default=(820, 616)) """Resolution to use for the MJPEG stream.""" - mjpeg_bitrate = lt.ThingProperty( - Optional[int], - initial_value=100000000, - ) + mjpeg_bitrate: Optional[int] = lt.property(default=100000000) """Bitrate for MJPEG stream (None for default).""" - stream_active = lt.ThingProperty( - bool, - initial_value=False, - observable=True, - readonly=True, - ) + stream_active: bool = lt.property(default=False, observable=True, readonly=True) """Whether the MJPEG stream is active.""" def save_settings(self) -> None: """Override save_settings to ensure that camera properties don't recurse. - This method is run by any Thing when a ThingSetting is saved. However, the + This method is run by any Thing when a setting is saved. However, the method reads the thing_setting. As reading the thing setting talks to the camera and calls save_settings if the value is not as expected, this could cause recursion. Also this means that saving one setting causes all others @@ -204,7 +193,7 @@ class StreamingPiCamera2(BaseCamera): finally: self._setting_save_in_progress = False - @lt.thing_property + @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating.""" # Check if the lens shading table is calibrated. @@ -214,7 +203,7 @@ class StreamingPiCamera2(BaseCamera): _analogue_gain: float = 1.0 - @lt.thing_setting + @lt.setting def analogue_gain(self) -> float: """The Analogue gain applied by the camera sensor.""" if not self._setting_save_in_progress and self.streaming: @@ -234,7 +223,7 @@ class StreamingPiCamera2(BaseCamera): _colour_gains: tuple[float, float] = (1.0, 1.0) - @lt.thing_setting + @lt.setting def colour_gains(self) -> tuple[float, float]: """The red and blue colour gains, must be between 0.0 and 32.0.""" if not self._setting_save_in_progress and self.streaming: @@ -254,7 +243,7 @@ class StreamingPiCamera2(BaseCamera): _exposure_time: int = 500 - @lt.thing_setting + @lt.setting def exposure_time(self) -> int: """The camera exposure time in microseconds. @@ -296,7 +285,7 @@ class StreamingPiCamera2(BaseCamera): _sensor_modes = None - @lt.thing_property + @lt.property def sensor_modes(self) -> list[SensorMode]: """All the available modes the current sensor supports.""" if not self._sensor_modes: @@ -306,7 +295,7 @@ class StreamingPiCamera2(BaseCamera): _sensor_mode: Optional[dict] = None - @lt.thing_property + @lt.property def sensor_mode(self) -> Optional[SensorModeSelector]: """The intended sensor mode of the camera.""" if self._sensor_mode is None: @@ -329,13 +318,13 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera(pause_stream=True): pass - @lt.thing_property + @lt.property def sensor_resolution(self) -> Optional[tuple[int, int]]: """The native resolution of the camera's sensor.""" with self._streaming_picamera() as cam: return cam.sensor_resolution - tuning = lt.ThingSetting(Optional[dict], None, readonly=True) + tuning: Optional[dict] = lt.setting(default=None, readonly=True) """The Raspberry PiCamera Tuning File JSON.""" def _initialise_picamera(self, check_sensor_model: bool = False) -> None: @@ -437,7 +426,7 @@ class StreamingPiCamera2(BaseCamera): cam.close() del self._picamera - @lt.thing_action + @lt.action def start_streaming( self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 6 ) -> None: @@ -509,7 +498,7 @@ class StreamingPiCamera2(BaseCamera): "Started MJPEG stream at %s on port %s", self.stream_resolution, 1 ) - @lt.thing_action + @lt.action def stop_streaming(self, stop_web_stream: bool = True) -> None: """Stop the MJPEG stream.""" with self._streaming_picamera() as picam: @@ -529,7 +518,7 @@ class StreamingPiCamera2(BaseCamera): # Adding a sleep to prevent camera getting confused by rapid commands time.sleep(self._sensor_info.short_pause) - @lt.thing_action + @lt.action def discard_frames(self) -> None: """Discard frames so that the next frame captured is fresh.""" with self._streaming_picamera() as cam: @@ -587,7 +576,7 @@ class StreamingPiCamera2(BaseCamera): else: raise ValueError(f'Unknown stream name "{stream_name}"') - @lt.thing_action + @lt.action def capture_array( self, stream_name: Literal["main", "lores", "raw", "full"] = "main", @@ -620,7 +609,7 @@ class StreamingPiCamera2(BaseCamera): # as array return np.array(self.capture_image(stream_name, wait)) - @lt.thing_property + @lt.property def camera_configuration(self) -> Mapping: """The "configuration" dictionary of the picamera2 object. @@ -635,13 +624,13 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera() as cam: return cam.camera_configuration() - @lt.thing_property + @lt.property def capture_metadata(self) -> dict: """Return the metadata from the camera.""" with self._streaming_picamera() as cam: return cam.capture_metadata() - @lt.thing_action + @lt.action def auto_expose_from_minimum( self, target_white_level: Optional[int] = None, @@ -671,7 +660,7 @@ class StreamingPiCamera2(BaseCamera): percentile=percentile, ) - @lt.thing_action + @lt.action def calibrate_lens_shading(self) -> None: """Take an image and use it for flat-field correction. @@ -699,7 +688,7 @@ class StreamingPiCamera2(BaseCamera): self.colour_gains = tf_utils.get_colour_gains_from_lst(self.tuning) - @lt.thing_property + @lt.property def colour_correction_matrix( self, ) -> tuple[float, float, float, float, float, float, float, float, float]: @@ -724,7 +713,7 @@ class StreamingPiCamera2(BaseCamera): with self._streaming_picamera(pause_stream=True): self._initialise_picamera() - @lt.thing_action + @lt.action def reset_ccm(self) -> None: """Overwrite the colour correction matrix in camera tuning with default values.""" self.tuning = tf_utils.copy_algo_from_other_tuning( @@ -733,7 +722,7 @@ class StreamingPiCamera2(BaseCamera): copy_from=self.default_tuning, ) - @lt.thing_action + @lt.action def set_static_green_equalisation(self, offset: int = 65535) -> None: """Set the green equalisation to a static value. @@ -748,7 +737,7 @@ class StreamingPiCamera2(BaseCamera): self.tuning = tf_utils.set_static_geq(self.tuning, offset) self._initialise_picamera() - @lt.thing_action + @lt.action def set_ce_enable_to_off(self) -> None: """Set the contrast enhancement to disabled. @@ -759,7 +748,7 @@ class StreamingPiCamera2(BaseCamera): self.tuning = tf_utils.set_ce_to_disabled(self.tuning) self._initialise_picamera() - @lt.thing_action + @lt.action def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None: """Perform a full auto-calibration. @@ -787,7 +776,7 @@ class StreamingPiCamera2(BaseCamera): pass raise RuntimeError("Couldn't set background") - @lt.thing_property + @lt.property def primary_calibration_actions(self) -> list[ActionButton]: """The calibration actions for both calibration wizard and settings panel.""" return [ @@ -805,7 +794,7 @@ class StreamingPiCamera2(BaseCamera): ), ] - @lt.thing_property + @lt.property def secondary_calibration_actions(self) -> list[ActionButton]: """The calibration actions that appear only in settings panel.""" return [ @@ -847,7 +836,7 @@ class StreamingPiCamera2(BaseCamera): ), ] - @lt.thing_property + @lt.property def manual_camera_settings(self) -> list[PropertyControl]: """The camera settings to expose as property controls in the settings panel.""" return [ @@ -874,7 +863,7 @@ class StreamingPiCamera2(BaseCamera): ), ] - @lt.thing_property + @lt.property def lens_shading_tables(self) -> Optional[tf_utils.LensShading]: """The current lens shading (i.e. flat-field correction). @@ -901,7 +890,7 @@ class StreamingPiCamera2(BaseCamera): ) self._initialise_picamera() - @lt.thing_action + @lt.action def flat_lens_shading(self) -> None: """Disable flat-field correction. @@ -916,7 +905,7 @@ class StreamingPiCamera2(BaseCamera): self.tuning = tf_utils.flatten_lst(self.tuning) self._initialise_picamera() - @lt.thing_action + @lt.action def flat_lens_shading_chrominance(self) -> None: """Disable flat-field correction for colour only. @@ -928,7 +917,7 @@ class StreamingPiCamera2(BaseCamera): self.tuning = tf_utils.flatten_lst(self.tuning, keep_luminance=True) self._initialise_picamera() - @lt.thing_action + @lt.action def reset_lens_shading(self) -> None: """Revert to default lens shading settings. diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index f0b80d0a..b26e69bb 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -86,7 +86,7 @@ class SimulatedCamera(BaseCamera): self.generate_blobs() self.generate_canvas() - @lt.thing_property + @lt.property def calibration_required(self) -> bool: """Whether the camera needs calibrating.""" return not self.background_detector_status.ready @@ -274,7 +274,7 @@ class SimulatedCamera(BaseCamera): self._capture_enabled = False self._capture_thread.join() - @lt.thing_action + @lt.action def start_streaming( self, main_resolution: tuple[int, int] = (820, 616), buffer_count: int = 1 ) -> None: @@ -300,14 +300,14 @@ class SimulatedCamera(BaseCamera): self._capture_thread = Thread(target=self._capture_frames) self._capture_thread.start() - @lt.thing_property + @lt.property def stream_active(self) -> bool: """Whether the MJPEG stream is active.""" if self._capture_enabled and self._capture_thread: return self._capture_thread.is_alive() return False - noise_level = lt.ThingProperty(float, 2.0) + noise_level: float = lt.property(default=2.0) def _capture_frames(self) -> None: portal = lt.get_blocking_portal(self) @@ -326,14 +326,14 @@ class SimulatedCamera(BaseCamera): except Exception as e: LOGGER.exception(f"Failed to capture frame: {e}, retrying...") - @lt.thing_action + @lt.action def discard_frames(self) -> None: """Discard frames so that the next frame captured is fresh. There is nothing to do as this is a simulation! """ - @lt.thing_action + @lt.action def capture_array( self, stream_name: Literal["main", "full"] = "full", @@ -370,7 +370,7 @@ class SimulatedCamera(BaseCamera): LOGGER.warning(f"Simulation camera camera doesn't respect {stream_name=}") return self.generate_frame() - @lt.thing_action + @lt.action def full_auto_calibrate(self, portal: lt.deps.BlockingPortal) -> None: """Perform a full auto-calibration. @@ -386,21 +386,21 @@ class SimulatedCamera(BaseCamera): time.sleep(0.2) self.load_sample() - @lt.thing_action + @lt.action def remove_sample(self) -> None: """Show the simulated background with no sample.""" if not self._show_sample: raise RuntimeError("Sample is already removed.") self._show_sample = False - @lt.thing_action + @lt.action def load_sample(self) -> None: """Show the simulated sample.""" if self._show_sample: raise RuntimeError("Sample is already in place.") self._show_sample = True - @lt.thing_property + @lt.property def primary_calibration_actions(self) -> list[ActionButton]: """The calibration actions for both calibration wizard and settings panel.""" return [ @@ -409,7 +409,7 @@ class SimulatedCamera(BaseCamera): ), ] - @lt.thing_property + @lt.property def secondary_calibration_actions(self) -> list[ActionButton]: """The calibration actions that appear only in settings panel.""" return [ @@ -417,7 +417,7 @@ class SimulatedCamera(BaseCamera): action_button_for(self.remove_sample, submit_label="Remove Sample"), ] - @lt.thing_property + @lt.property def manual_camera_settings(self) -> list[PropertyControl]: """The camera settings to expose as property controls in the settings panel.""" return [property_control_for(self, "noise_level", label="Noise Level")] diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 88a3393d..3c0f624c 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -118,7 +118,7 @@ class CameraStageMapper(lt.Thing): override the ``get_xyz_position()`` and ``move_to_xyz_position()`` methods. """ - @lt.thing_action + @lt.action def calibrate_1d( self, camera: CameraClient, @@ -154,7 +154,7 @@ class CameraStageMapper(lt.Thing): result["image_resolution"] = camera.capture_downsampled_array().shape[:2] return result - @lt.thing_action + @lt.action def calibrate_xy( self, camera: CameraClient, stage: Stage, logger: lt.deps.InvocationLogger ) -> DenumpifyingDict: @@ -200,7 +200,7 @@ class CameraStageMapper(lt.Thing): return data - @lt.thing_property + @lt.property def image_to_stage_displacement_matrix( self, ) -> Optional[List[List[float]]]: # 2x2 integer array @@ -230,19 +230,17 @@ class CameraStageMapper(lt.Thing): ] return np.array(displacement_matrix).tolist() - last_calibration = lt.ThingSetting( - initial_value=None, model=Optional[dict], readonly=True - ) + last_calibration: Optional[dict] = lt.setting(default=None, readonly=True) """The most recent CSM calibration.""" - @lt.thing_property + @lt.property def image_resolution(self) -> Optional[Tuple[float, float]]: """The image size used to calibrate the image_to_stage_displacement_matrix.""" if self.last_calibration is None: return None return self.last_calibration["image_resolution"] - @lt.thing_property + @lt.property def calibration_required(self) -> bool: """Whether the camera stage mapper needs calibrating.""" return self.image_to_stage_displacement_matrix is None @@ -254,7 +252,7 @@ class CameraStageMapper(lt.Thing): # added by CSMUncalibratedError raise CSMUncalibratedError() # noqa: RSE102 - @lt.thing_action + @lt.action def move_in_image_coordinates( self, stage: Stage, @@ -275,7 +273,7 @@ class CameraStageMapper(lt.Thing): self.assert_calibrated() stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y)) - @lt.thing_action + @lt.action def convert_image_to_stage_coordinates( self, x: float, y: float, **_kwargs: float ) -> Mapping[str, int]: @@ -283,7 +281,7 @@ class CameraStageMapper(lt.Thing): self.assert_calibrated() return csm_img_to_stage(self.image_to_stage_displacement_matrix, x=x, y=y) - @lt.thing_action + @lt.action def convert_stage_to_image_coordinates( self, x: int, y: int, **_kwargs: int ) -> Mapping[str, float]: @@ -291,7 +289,7 @@ class CameraStageMapper(lt.Thing): self.assert_calibrated() return csm_stage_to_img(self.image_to_stage_displacement_matrix, x=x, y=y) - @lt.thing_property + @lt.property def thing_state(self) -> Mapping[str, Any]: """Summary metadata describing the current state of the Thing.""" return { diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index eefa6b65..0704da71 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -117,7 +117,7 @@ class SmartScanThing(lt.Thing): self._latest_scan_name: Optional[str] = None # Scan logger is the invocation logger labthings-fastapi creates - # when the `sample_scan` lt.thing_action is called. It is saved as + # when the `sample_scan` lt.action is called. It is saved as # private class variable along with many others here. # Access to these variables requires a scan to be running, # any method that calls these should be decorated with @@ -133,7 +133,7 @@ class SmartScanThing(lt.Thing): self._scan_data: Optional[scan_directories.ScanData] = None self._preview_stitcher: Optional[stitching.PreviewStitcher] = None - @lt.thing_action + @lt.action def sample_scan( self, cancel: lt.deps.CancelHook, @@ -231,7 +231,7 @@ class SmartScanThing(lt.Thing): "of motion." ) - @lt.thing_property + @lt.property def latest_scan_name(self) -> Optional[str]: """The name of the last scan to be started.""" return self._latest_scan_name @@ -546,48 +546,27 @@ class SmartScanThing(lt.Thing): raise HTTPException(404, "File not found") return FileResponse(preview_path) - save_resolution = lt.ThingSetting( - initial_value=(1640, 1232), - model=tuple[int, int], - ) + save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232)) """A tuple of the image resolution to capture.""" - max_range = lt.ThingSetting( - initial_value=45000, - model=int, - ) + max_range: int = lt.setting(default=45000) """The maximum distance in steps from the centre of the scan.""" - stitch_tiff = lt.ThingSetting( - initial_value=False, - model=bool, - ) + stitch_tiff: bool = lt.setting(default=False) """Whether or not to also produce a pyramidal tiff at the end of a scan.""" - skip_background = lt.ThingSetting( - initial_value=True, - model=bool, - ) + skip_background: bool = lt.setting(default=True) """Whether to detect and skip empty fields of view. This uses the settings from the ``BackgroundDetectThing``.""" - autofocus_dz = lt.ThingSetting( - initial_value=1000, - model=int, - ) + autofocus_dz: int = lt.setting(default=1000) """The z distance to perform an autofocus in steps.""" - overlap = lt.ThingSetting( - initial_value=0.45, - model=float, - ) + overlap: float = lt.setting(default=0.45) """The fraction (0-1) that adjacent images should overlap in x or y.""" - stitch_automatically = lt.ThingSetting( - initial_value=True, - model=bool, - ) + stitch_automatically: bool = lt.setting(default=True) """Whether to run a final stitch at the end of a successful scan.""" def _get_all_scan_info(self) -> list[scan_directories.ScanInfo]: @@ -600,7 +579,7 @@ class SmartScanThing(lt.Thing): ongoing_name = None if self._ongoing_scan is None else self._ongoing_scan.name return self._scan_dir_manager.all_scans_info(ongoing=ongoing_name) - @lt.thing_property + @lt.property def scans(self) -> ScanListInfo: """All the available scans. @@ -675,7 +654,7 @@ class SmartScanThing(lt.Thing): for scan_name in self._scan_dir_manager.all_scans: self._delete_scan(scan_name, logger) - @lt.thing_action + @lt.action def purge_empty_scans(self, logger: lt.deps.InvocationLogger) -> None: """Delete all scan folders containing no images at the top level.""" # JSON is ignored as it's created before any images are captured @@ -712,7 +691,7 @@ class SmartScanThing(lt.Thing): scan_name=self.latest_scan_name, filename="preview.jpg", check_exists=True ) - @lt.thing_property + @lt.property def latest_preview_stitch_time(self) -> Optional[float]: """The modification time of the latest preview image, to allow live updating. @@ -746,7 +725,7 @@ class SmartScanThing(lt.Thing): raise HTTPException(404, "File not found") return FileResponse(preview_path) - @lt.thing_action + @lt.action def stitch_scan( self, logger: lt.deps.InvocationLogger, @@ -757,7 +736,7 @@ class SmartScanThing(lt.Thing): ) -> None: """Generate a stitched image based on stage position metadata. - Note that as this is a lt.thing_action it needs the logger passed as + Note that as this is a lt.action it needs the logger passed as a variable if called from another thing action """ scan_data_dict = self._scan_dir_manager.get_scan_data_dict(scan_name) @@ -780,7 +759,7 @@ class SmartScanThing(lt.Thing): except SubprocessError as e: self._scan_logger.error(f"Stitching failed: {e}", exc_info=e) - @lt.thing_action + @lt.action def download_zip( self, scan_name: str, @@ -792,7 +771,7 @@ class SmartScanThing(lt.Thing): zip_fname = self._scan_dir_manager.zip_scan(scan_name, final_version=True) return ZipBlob.from_file(zip_fname) - @lt.thing_action + @lt.action def stitch_all_scans( self, logger: lt.deps.InvocationLogger, cancel: lt.deps.CancelHook ) -> None: diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 0e92c2c6..1d2377cd 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -68,28 +68,21 @@ class BaseStage(lt.Thing): "_hardware_move_relative and/or _hardware_move_absolute instead." ) - @lt.thing_property + @lt.property def axis_names(self) -> Sequence[str]: """The names of the stage's axes, in order.""" return self._axis_names - @lt.thing_property + @lt.property def position(self) -> Mapping[str, int]: """Current position of the stage.""" return self._apply_axis_direction(self._hardware_position) - moving = lt.ThingProperty( - bool, - False, - readonly=True, - observable=True, - ) + moving: bool = lt.property(bool, default=False, readonly=True, observable=True) """Whether the stage is in motion.""" - axis_inverted = lt.ThingSetting( - initial_value={"x": False, "y": False, "z": False}, - model=Mapping[str, bool], - readonly=True, + axis_inverted: Mapping[str, bool] = lt.setting( + default={"x": False, "y": False, "z": False}, readonly=True ) """Used to convert coordinates between the program frame and the hardware frame.""" @@ -124,7 +117,7 @@ class BaseStage(lt.Thing): """Summary metadata describing the current state of the stage.""" return {"position": self.position} - @lt.thing_action + @lt.action def invert_axis_direction(self, axis: Literal["x", "y", "z"]) -> None: """Invert the direction setting of the given axis. @@ -138,7 +131,7 @@ class BaseStage(lt.Thing): raise KeyError(f"The axis {axis} is not defined.") from e self.axis_inverted = direction - @lt.thing_action + @lt.action def move_relative( self, cancel: lt.deps.CancelHook, @@ -166,7 +159,7 @@ class BaseStage(lt.Thing): "StageThings must define their own _hardware_move_relative method" ) - @lt.thing_action + @lt.action def move_absolute( self, cancel: lt.deps.CancelHook, @@ -194,7 +187,7 @@ class BaseStage(lt.Thing): "StageThings must define their own move_absolute method" ) - @lt.thing_action + @lt.action def set_zero_position(self) -> None: """Make the current position zero in all axes. @@ -206,7 +199,7 @@ class BaseStage(lt.Thing): "StageThings must define their own set_zero_position method" ) - @lt.thing_action + @lt.action def get_xyz_position(self) -> tuple[int, int, int]: """Return a tuple containing (x, y, z) position. @@ -217,7 +210,7 @@ class BaseStage(lt.Thing): position_dict = self.position return (position_dict["x"], position_dict["y"], position_dict["z"]) - @lt.thing_action + @lt.action def move_to_xyz_position( self, cancel: lt.deps.CancelHook, xyz_pos: tuple[int, int, int] ) -> None: diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index a2572908..ff83dddb 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -43,10 +43,8 @@ class DummyStage(BaseStage): ) -> None: """Nothing to do when the Thing context manager is closed.""" - axis_inverted = lt.ThingSetting( - initial_value={"x": True, "y": False, "z": False}, - model=Mapping[str, bool], - readonly=True, + axis_inverted: Mapping[str, bool] = lt.setting( + default={"x": True, "y": False, "z": False}, readonly=True ) """Used to convert coordinates between the program frame and the hardware frame.""" @@ -104,7 +102,7 @@ class DummyStage(BaseStage): cancel, block_cancellation=block_cancellation, **displacement ) - @lt.thing_action + @lt.action def set_zero_position(self) -> None: """Make the current position zero in all axes. diff --git a/src/openflexure_microscope_server/things/stage/sangaboard.py b/src/openflexure_microscope_server/things/stage/sangaboard.py index 7432480b..7748ca28 100644 --- a/src/openflexure_microscope_server/things/stage/sangaboard.py +++ b/src/openflexure_microscope_server/things/stage/sangaboard.py @@ -78,10 +78,8 @@ class SangaboardThing(BaseStage): with self._sangaboard_lock: yield self._sangaboard - axis_inverted = lt.ThingSetting( - initial_value={"x": True, "y": False, "z": True}, - model=Mapping[str, bool], - readonly=True, + axis_inverted: Mapping[str, bool] = lt.setting( + default={"x": True, "y": False, "z": True}, readonly=True ) """Used to convert coordinates between the program frame and the hardware frame.""" @@ -164,7 +162,7 @@ class SangaboardThing(BaseStage): cancel, block_cancellation=block_cancellation, **displacement ) - @lt.thing_action + @lt.action def set_zero_position(self) -> None: """Make the current position zero in all axes. @@ -176,7 +174,7 @@ class SangaboardThing(BaseStage): sb.zero_position() self.update_position() - @lt.thing_action + @lt.action def flash_led( self, number_of_flashes: int = 10, diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c4dd9613..84cabe36 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -136,9 +136,7 @@ class ParasiticMotionError(Exception): class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" - calibrated_range = lt.ThingSetting( - initial_value=None, model=Optional[list[int, int]], readonly=True - ) + calibrated_range: int = lt.setting(default=None, readonly=True) def __init__(self) -> None: """Initialise and create the lock.""" @@ -147,7 +145,7 @@ class RangeofMotionThing(lt.Thing): self._stream_resolution: Optional[tuple[int, int]] = None self._rom_data = RomDataTracker() - @lt.thing_action + @lt.action def perform_rom_test( self, autofocus: AutofocusDep, @@ -214,7 +212,7 @@ class RangeofMotionThing(lt.Thing): "Step Range": step_range, } - @lt.thing_action + @lt.action def perform_recentre( self, autofocus: AutofocusDep, diff --git a/src/openflexure_microscope_server/things/system.py b/src/openflexure_microscope_server/things/system.py index d8a785e6..2d5fa63a 100644 --- a/src/openflexure_microscope_server/things/system.py +++ b/src/openflexure_microscope_server/things/system.py @@ -43,7 +43,7 @@ class OpenFlexureSystem(lt.Thing): _microscope_id: Optional[str] = None - @lt.thing_setting + @lt.setting def microscope_id(self) -> UUID: """A unique identifier for this microscope.""" if self._microscope_id is None: @@ -54,14 +54,14 @@ class OpenFlexureSystem(lt.Thing): def microscope_id(self, uuid: UUID) -> None: self._microscope_id = uuid - @lt.thing_property + @lt.property def hostname(self) -> str: """The hostname of the microscope, as reported by its operating system.""" return socket.gethostname() _version_data: Optional[VersionData] = None - @lt.thing_property + @lt.property def version_data(self) -> VersionData: """The version string and version source for the server. @@ -76,12 +76,12 @@ class OpenFlexureSystem(lt.Thing): self._version_data = robust_version_strings() return self._version_data - @lt.thing_property + @lt.property def is_raspberrypi(self) -> bool: """Return True if running on a Raspberry Pi.""" return os.path.exists("/usr/bin/raspi-config") - @lt.thing_action + @lt.action def shutdown(self) -> CommandOutput: """Attempt to shutdown the device.""" if not self.is_raspberrypi: @@ -103,7 +103,7 @@ class OpenFlexureSystem(lt.Thing): out, err = p.communicate() return CommandOutput(output=out, error=err) - @lt.thing_action + @lt.action def reboot(self) -> CommandOutput: """Attempt to reboot the device.""" if not self.is_raspberrypi: @@ -120,7 +120,7 @@ class OpenFlexureSystem(lt.Thing): out, err = p.communicate() return CommandOutput(output=out, error=err) - @lt.thing_action + @lt.action def get_things_state(self, metadata_getter: lt.deps.GetThingStates) -> Mapping: """Metadata summarising the current state of all Things in the server.""" return metadata_getter() diff --git a/tests/test_stage.py b/tests/test_stage.py index b735ab4c..a95d0b33 100644 --- a/tests/test_stage.py +++ b/tests/test_stage.py @@ -63,7 +63,7 @@ def test_override_base_movement(): """ class BadStage1(BaseStage): - @lt.thing_action + @lt.action def move_relative( self, cancel: lt.deps.CancelHook, @@ -76,7 +76,7 @@ def test_override_base_movement(): BadStage1() class BadStage2(BaseStage): - @lt.thing_action + @lt.action def move_absolute( self, cancel: lt.deps.CancelHook,