update action, property, and setting syntax for labthings-fastapi 0.0.12

This commit is contained in:
Julian Stirling 2025-12-14 11:52:07 +00:00
parent bbbfaf8602
commit 5eea78cac7
14 changed files with 139 additions and 192 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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