diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index 81cd7d1f03..65d7f6413a 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -10,6 +10,8 @@ from .clario_star_backend import CLARIOstarBackend from .image_reader import ImageReader from .imager import Imager +from .molecular_devices_spectramax_384_plus_backend import MolecularDevicesSpectraMax384PlusBackend +from .molecular_devices_spectramax_m5_backend import MolecularDevicesSpectraMaxM5Backend from .plate_reader import PlateReader from .standard import ( Exposure, @@ -19,3 +21,4 @@ ImagingResult, Objective, ) +from .tecan.spark20m.spark_backend import SparkBackend diff --git a/pylabrobot/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/plate_reading/tecan/spark20m/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py b/pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py new file mode 100644 index 0000000000..c046e6b60d --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py @@ -0,0 +1,11 @@ +from .base_control import baseControl +from .camera_control import cameraControl +from .config_control import ConfigControl +from .data_control import DataControl +from .injector_control import InjectorControl +from .measurement_control import measurement_control +from .movement_control import movement_control +from .optics_control import OpticsControl +from .plate_transport_control import plateControl +from .sensor_control import SensorControl +from .system_control import SystemControl diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py new file mode 100644 index 0000000000..28215b5c5d --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py @@ -0,0 +1,3 @@ +class baseControl: + def __init__(self, reader): + self.send_command = reader.send_command diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py new file mode 100644 index 0000000000..8207e8a53b --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py @@ -0,0 +1,409 @@ +import logging + +from .base_control import baseControl +from .spark_enums import BrightnessState, CameraMode, FlippingMode, TriggerMode + + +class cameraControl(baseControl): + async def initialize_camera(self, ini_file_path=None): + """Initializes the camera.""" + command = "CAMERA INIT" + if ini_file_path: + command += f" INIPATH={ini_file_path}" + return await self.send_command(command) + + async def is_camera_initialized(self): + """Checks if the camera is initialized.""" + response = await self.send_command("?CAMERA ISINITIALIZED") + return response == "ISINITIALIZED=TRUE" + + async def reset_camera(self): + """Resets the camera.""" + return await self.send_command("CAMERA RESET") + + async def close_camera(self): + """Closes the camera connection.""" + return await self.send_command("CAMERA CLOSE") + + async def acquire_camera_instance(self, instance_guid): + """Acquires the camera instance.""" + return await self.send_command(f"CAMERA ACQUIREINSTANCEGUID={instance_guid.upper()}") + + async def release_camera_instance(self): + """Releases the camera instance.""" + return await self.send_command("CAMERA RELEASEINSTANCEGUID") + + async def set_camera_pixel_clock(self, pixel_clock): + """Sets the camera pixel clock.""" + return await self.send_command(f"CAMERA PIXELCLOCK={pixel_clock}") + + async def set_camera_maximal_pixel_clock(self): + """Sets the camera to its maximal pixel clock.""" + return await self.send_command("CAMERA MAXPIXELCLOCK") + + async def set_camera_bits_per_pixel(self, bits_per_pixel): + """Sets the camera bits per pixel.""" + return await self.send_command(f"CAMERA BITSPERPIXEL={bits_per_pixel}") + + async def set_camera_mode(self, mode: CameraMode): + """Sets the camera mode.""" + return await self.send_command(f"CAMERA MODE={mode.value}") + + async def set_camera_exposure_time(self, time): + """Sets the camera exposure time.""" + return await self.send_command(f"CAMERA EXPOSURETIME={time}") + + async def set_camera_gain(self, gain): + """Sets the camera gain.""" + return await self.send_command(f"CAMERA GAIN={gain}") + + async def set_camera_area_of_interest(self, x, y, width, height): + """Sets the camera area of interest.""" + return await self.send_command(f"CAMERA AOI X={x} Y={y} WIDTH={width} HEIGHT={height}") + + async def set_camera_flipping_mode(self, flipping_mode: FlippingMode): + """Sets the camera flipping mode.""" + return await self.send_command(f"CAMERA FLIPPINGMODE={flipping_mode.value}") + + async def set_camera_black_level(self, black_level): + """Sets the camera black level.""" + return await self.send_command(f"CAMERA BLACKLEVEL={black_level}") + + async def optimize_camera_brightness( + self, + aperture_setting=None, + exposure_start_time=None, + start_gain=None, + max_gain=None, + max_exposure_time=None, + target_value=None, + min_percent=None, + max_percent=None, + ): + """Optimizes the camera brightness.""" + command = "CAMERA OPTIMIZE" + if aperture_setting: + command += f" APERTURE={aperture_setting.upper()}" + elif all( + v is not None + for v in [ + exposure_start_time, + start_gain, + max_gain, + max_exposure_time, + target_value, + min_percent, + max_percent, + ] + ): + command += f" BRIGHTNESS EXPOSURESTARTTIME={exposure_start_time} STARTGAIN={start_gain} MAXGAIN={max_gain} MAXEXPOSURETIME={max_exposure_time} TARGETVALUE={target_value} MINPERCENT={min_percent} MAXPERCENT={max_percent}" + else: + logging.error("Invalid parameters for optimize_camera_brightness") + return None + return await self.send_command(command) + + async def take_camera_image(self, cell_camera_image_type=None, retries=3, timeout_ms=None): + """Takes an image with the camera.""" + command = "CAMERA TAKEIMAGE" + if cell_camera_image_type: + command += f" TYPE={cell_camera_image_type.upper()} RETRIES={retries}" + elif timeout_ms: + command += f" TIMEOUT={timeout_ms}" + else: + logging.error("Invalid parameters for take_camera_image") + return None + return await self.send_command(command) + + async def set_camera_trigger_mode(self, mode: TriggerMode): + """Sets the camera trigger mode.""" + return await self.send_command(f"CAMERA TRIGGERMODE={mode.value}") + + async def prepare_take_camera_image(self): + """Prepares the camera for taking an image.""" + return await self.send_command("CAMERA PREPARETAKEIMAGE") + + async def fetch_camera_image(self, timeout_ms=5000): + """Fetches the image from the camera.""" + return await self.send_command(f"CAMERA FETCHIMAGE TIMEOUT={timeout_ms}") + + async def clear_camera_autofocus_result(self): + """Clears the autofocus result.""" + return await self.send_command("CAMERA AUTOFOCUS CLEAR") + + async def get_camera_instance_guid(self): + """Gets the camera instance GUID.""" + return await self.send_command("?CAMERA INSTANCEGUID") + + async def get_current_camera_trigger_mode(self): + """Gets the current camera trigger mode.""" + return await self.send_command("?CAMERA TRIGGERMODE") + + async def get_available_camera_trigger_modes(self): + """Gets the available camera trigger modes.""" + response = await self.send_command("#CAMERA TRIGGERMODE") + return response + + async def get_current_camera_pixel_clock(self): + """Gets the current camera pixel clock.""" + return await self.send_command("?CAMERA PIXELCLOCK") + + async def get_camera_pixel_clock_range(self): + """Gets the camera pixel clock range.""" + return await self.send_command("#CAMERA PIXELCLOCK") + + async def get_allowed_camera_pixel_clocks(self): + """Gets the allowed camera pixel clocks.""" + response = await self.send_command("#CAMERA CONFIG ALLOWEDPIXELCLOCKS") + return response + + async def get_current_camera_mode(self): + """Gets the current camera mode.""" + return await self.send_command("?CAMERA MODE") + + async def get_camera_autofocus_image_count(self): + """Gets the number of images taken during autofocus.""" + return await self.send_command("?CAMERA AUTOFOCUS IMAGECOUNT") + + async def get_camera_autofocus_details(self, image_number): + """Gets the autofocus details for a specific image number.""" + return await self.send_command(f"?CAMERA AUTOFOCUSDETAIL IMAGE={image_number}") + + async def get_allowed_camera_exposure_time(self): + """Gets the allowed camera exposure time range.""" + return await self.send_command("#CAMERA EXPOSURETIME") + + async def get_current_camera_exposure_time(self): + """Gets the current camera exposure time.""" + return await self.send_command("?CAMERA EXPOSURETIME") + + async def get_allowed_camera_area_of_interest_property(self, area_property): + """Gets the allowed range for a specific area of interest property.""" + return await self.send_command(f"#CAMERA AOI {area_property.upper()}") + + async def get_current_camera_area_of_interest(self): + """Gets the current camera area of interest.""" + return await self.send_command("?CAMERA AOI") + + async def get_allowed_camera_area_of_interest(self): + """Gets the maximum allowed camera area of interest.""" + return await self.send_command("?CAMERA MAXAOI") + + async def get_minimal_camera_area_of_interest(self): + """Gets the minimal allowed camera area of interest.""" + return await self.send_command("?CAMERA MINAOI") + + async def get_current_camera_gain(self): + """Gets the current camera gain.""" + return await self.send_command("?CAMERA GAIN") + + async def get_current_camera_flipping_mode(self): + """Gets the current camera flipping mode.""" + return await self.send_command("?CAMERA FLIPPINGMODE") + + async def get_current_camera_black_level(self): + """Gets the current camera black level.""" + return await self.send_command("?CAMERA BLACKLEVEL") + + async def get_camera_error(self): + """Gets the camera error.""" + return await self.send_command("?CAMERA ERROR") + + async def get_camera_instrument_serial_number(self): + """Gets the camera instrument serial number.""" + return await self.send_command("?CAMERA INSTRUMENTSERIALNUMBER") + + async def terminate_camera(self): + """Terminates the camera.""" + return await self.send_command("CAMERA TERMINATE") + + async def get_camera_number_of_warnings(self): + """Gets the number of camera warnings.""" + return await self.send_command("?CAMERA WARNING COUNT") + + async def get_camera_pixel_size(self): + """Gets the camera pixel size.""" + return await self.send_command("?CAMERA CONFIG PIXELSIZE") + + async def get_current_camera_bits_per_pixel(self): + """Gets the current camera bits per pixel.""" + return await self.send_command("?CAMERA BITSPERPIXEL") + + async def get_allowed_camera_bits_per_pixel(self): + """Gets the allowed camera bits per pixel.""" + response = await self.send_command("#CAMERA BITSPERPIXEL") + return response + + async def get_camera_warning(self, index): + """Gets the camera warning at the given index.""" + return await self.send_command(f"?CAMERA WARNING INDEX={index}") + + async def clear_camera_warnings(self): + """Clears the camera warnings.""" + return await self.send_command("CAMERA WARNINGS CLEAR") + + async def probe_camera(self, max_performance): + """Probes the camera performance.""" + return await self.send_command(f"CAMERA PROBE MAXPERFORMANCE={max_performance}") + + async def prepare_camera(self, brightness: BrightnessState): + """Prepares the camera.""" + return await self.send_command(f"CAMERA PREPARE BRIGHTNESS={brightness.value}") + + async def get_camera_driver_version(self): + """Gets the camera driver version.""" + return await self.send_command("?INFO HARDWARE_VERSION") + + async def set_camera_aoi(self, x, y, width, height): + """Sets the camera area of interest.""" + return await self.send_command(f"CAMERA AOI X={x} Y={y} WIDTH={width} HEIGHT={height}") + + async def get_allowed_camera_aoi_property(self, area_property): + """Gets the allowed range for a specific area of interest property.""" + return await self.send_command(f"#CAMERA AOI {area_property.upper()}") + + async def get_current_camera_aoi(self): + """Gets the current camera area of interest.""" + return await self.send_command("?CAMERA AOI") + + async def get_allowed_camera_aoi(self): + """Gets the maximum allowed camera area of interest.""" + return await self.send_command("?CAMERA MAXAOI") + + async def get_minimal_camera_aoi(self): + """Gets the minimal allowed camera area of interest.""" + return await self.send_command("?CAMERA MINAOI") + + async def clear_camera_af_result(self): + """Clears the autofocus result.""" + return await self.send_command("CAMERA AUTOFOCUS CLEAR") + + async def get_camera_af_image_count(self): + """Gets the number of images taken during autofocus.""" + return await self.send_command("?CAMERA AUTOFOCUS IMAGECOUNT") + + async def get_camera_af_details(self, image_number): + """Gets the autofocus details for a specific image number.""" + return await self.send_command(f"?CAMERA AUTOFOCUSDETAIL IMAGE={image_number}") + + async def set_bpp(self, bits_per_pixel): + """Sets the camera bits per pixel.""" + return await self.send_command(f"CAMERA BITSPERPIXEL={bits_per_pixel}") + + async def get_current_bpp(self): + """Gets the current camera bits per pixel.""" + return await self.send_command("?CAMERA BITSPERPIXEL") + + async def get_allowed_bpp(self): + """Gets the allowed camera bits per pixel.""" + response = await self.send_command("#CAMERA BITSPERPIXEL") + return response + + async def get_pixel_size(self): + """Gets the camera pixel size.""" + return await self.send_command("?CAMERA CONFIG PIXELSIZE") + + async def set_cam_black_level(self, black_level): + """Sets the camera black level.""" + return await self.send_command(f"CAMERA BLACKLEVEL={black_level}") + + async def get_current_cam_black_level(self): + """Gets the current camera black level.""" + return await self.send_command("?CAMERA BLACKLEVEL") + + async def set_camera_instrument_serial_number(self, serial_number): + """Sets the camera instrument serial number.""" + return await self.send_command(f"CAMERA INSTRUMENTSERIALNUMBER={serial_number}") + + async def get_allowed_exposure_time(self): + """Gets the allowed camera exposure time range.""" + return await self.send_command("#CAMERA EXPOSURETIME") + + async def get_current_exposure_time(self): + """Gets the current camera exposure time.""" + return await self.send_command("?CAMERA EXPOSURETIME") + + async def set_exposure_time(self, time): + """Sets the camera exposure time.""" + return await self.send_command(f"CAMERA EXPOSURETIME={time}") + + async def optimize_brightness( + self, + aperture_setting=None, + exposure_start_time=None, + start_gain=None, + max_gain=None, + max_exposure_time=None, + target_value=None, + min_percent=None, + max_percent=None, + ): + """Optimizes the camera brightness.""" + command = "CAMERA OPTIMIZE" + if aperture_setting: + command += f" APERTURE={aperture_setting.upper()}" + elif all( + v is not None + for v in [ + exposure_start_time, + start_gain, + max_gain, + max_exposure_time, + target_value, + min_percent, + max_percent, + ] + ): + command += f" BRIGHTNESS EXPOSURESTARTTIME={exposure_start_time} STARTGAIN={start_gain} MAXGAIN={max_gain} MAXEXPOSURETIME={max_exposure_time} TARGETVALUE={target_value} MINPERCENT={min_percent} MAXPERCENT={max_percent}" + else: + logging.error("Invalid parameters for optimize_camera_brightness") + return None + return await self.send_command(command) + + async def prepare_take_image_external_trigger(self): + """Prepares the camera for taking an image using an external trigger.""" + return await self.send_command("CAMERA PREPARETAKEIMAGE") + + async def fetch_image_external_trigger(self, timeout_ms=5000): + """Fetches the image from the camera after an external trigger.""" + return await self.send_command(f"CAMERA FETCHIMAGE TIMEOUT={timeout_ms}") + + async def set_cam_flipping_mode(self, flipping_mode: FlippingMode): + """Sets the camera flipping mode.""" + return await self.send_command(f"CAMERA FLIPPINGMODE={flipping_mode.value}") + + async def get_current_cam_flipping_mode(self): + """Gets the current camera flipping mode.""" + return await self.send_command("?CAMERA FLIPPINGMODE") + + async def set_cam_gain(self, gain): + """Sets the camera gain.""" + return await self.send_command(f"CAMERA GAIN={gain}") + + async def get_current_cam_gain(self): + """Gets the current camera gain.""" + return await self.send_command("?CAMERA GAIN") + + async def set_cam_pixel_clock(self, pixel_clock): + """Sets the camera pixel clock.""" + return await self.send_command(f"CAMERA PIXELCLOCK={pixel_clock}") + + async def set_cam_maximal_pixel_clock(self): + """Sets the camera to its maximal pixel clock.""" + return await self.send_command("CAMERA MAXPIXELCLOCK") + + async def get_current_cam_pixel_clock(self): + """Gets the current camera pixel clock.""" + return await self.send_command("?CAMERA PIXELCLOCK") + + async def get_cam_pixel_clock_range(self): + """Gets the camera pixel clock range.""" + return await self.send_command("#CAMERA PIXELCLOCK") + + async def get_allowed_cam_pixel_clocks(self): + """Gets the allowed camera pixel clocks.""" + response = await self.send_command("#CAMERA CONFIG ALLOWEDPIXELCLOCKS") + return response + + async def probe_cam(self, max_performance): + """Probes the camera performance.""" + return await self.send_command(f"CAMERA PROBE MAXPERFORMANCE={max_performance}") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py new file mode 100644 index 0000000000..7854815571 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py @@ -0,0 +1,416 @@ +from typing import Optional + +from .base_control import baseControl +from .spark_enums import ConfigAxis, ModuleType + + +class ConfigControl(baseControl): + async def get_config_expected_modules(self): + """Gets the expected modules from configuration.""" + return await self.send_command("?CONFIG MODULE EXPECTED") + + async def set_config_expected_modules(self, module_details_list): + """Sets the expected modules in configuration.""" + modules_str = "|".join([f"{m.Name}:{m.Number}" for m in module_details_list]) + return await self.send_command(f"CONFIG MODULE EXPECTED={modules_str}") + + async def get_config_expected_usb_modules(self): + """Gets the expected USB modules from configuration.""" + return await self.send_command("?CONFIG MODULE EXPECTED_USB") + + async def set_config_expected_usb_modules(self, module_details_list): + """Sets the expected USB modules in configuration.""" + modules_str = "|".join([f"{m.Name}:{m.Number}" for m in module_details_list]) + return await self.send_command(f"CONFIG MODULE EXPECTED_USB={modules_str}") + + async def get_config_sap_instrument_serial_number(self): + """Gets the SAP instrument serial number from configuration.""" + return await self.send_command("?CONFIG IDENTIFICATION SAP_SERIAL_INSTR") + + async def set_config_sap_instrument_serial_number( + self, serial_number, module: Optional[ModuleType] = None, sub_module=None + ): + """Sets the SAP instrument serial number in configuration.""" + command = f"CONFIG IDENTIFICATION SAP_SERIAL_INSTR={serial_number}" + if module: + command += f" MODULE={module.value}" + if sub_module: + command += f" SUB={sub_module}" + return await self.send_command(command) + + async def set_config_module_serial_number( + self, serial_number, module: ModuleType, module_number, sub_module=None + ): + """Sets the module serial number in configuration.""" + command = ( + f"CONFIG INFO SAP_NR_MODULE={serial_number} MODULE={module.value} NUMBER={module_number}" + ) + if sub_module: + command += f" SUB={sub_module}" + return await self.send_command(command) + + async def configure_home_direction( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the home direction for a motor.""" + command = f"CONFIG INIT MOTOR={motor} HOMEDIRECTION={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def configure_home_level_position( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the home level position for a motor.""" + command = f"CONFIG INIT MOTOR={motor} HOMELEVEL={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def configure_home_sensor_position( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the home sensor position for a motor.""" + command = f"CONFIG INIT MOTOR={motor} HOMESENSOR={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def configure_init_position( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the initial position for a motor.""" + command = f"CONFIG INIT MOTOR={motor} INITPOS={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def configure_max_home( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the maximum home position for a motor.""" + command = f"CONFIG INIT MOTOR={motor} MAXHOME={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def configure_max_out_of_home( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Configures the maximum out of home position for a motor.""" + command = f"CONFIG INIT MOTOR={motor} MAXOUTOFHOME={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_home_direction( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the home direction for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} HOMEDIRECTION" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_home_level_position( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the home level position for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} HOMELEVEL" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_home_sensor_position( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the home sensor position for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} HOMESENSOR" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_init_position( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the initial position for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} INITPOS" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_max_home( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the maximum home position for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} MAXHOME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_max_out_of_home( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the maximum out of home position for a motor.""" + command = f"?CONFIG INIT MOTOR={motor} MAXOUTOFHOME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_config_limit_value( + self, name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the configured limit value.""" + command = "?CONFIG LIMIT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {name.upper()}" + return await self.send_command(command) + + async def set_config_limit_value( + self, value, name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Sets the configured limit value.""" + command = "CONFIG LIMIT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {name.upper()}={value}" + return await self.send_command(command) + + async def init_module( + self, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + excluded_modules=None, + ): + """Initializes the specified module or all modules if none specified. + Can exclude modules from initialization. + """ + command = "INIT" + if excluded_modules: + excluded_str = "|".join(excluded_modules) + command += f" WITHOUT={excluded_str}" + else: + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def init_motor( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Initializes the specified motor.""" + command = f"INIT MOTOR={motor}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_initializable_motors( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of initializable motors.""" + command = "#INIT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def set_offset(self, axis: ConfigAxis, offset): + """Sets the offset for a given axis (X, Y, Z).""" + return await self.send_command(f"CONFIG OFFSET {axis.value}={offset}") + + async def get_offset(self, axis: ConfigAxis): + """Gets the offset for a given axis (X, Y, Z).""" + return await self.send_command(f"?CONFIG OFFSET {axis.value}") + + async def set_x_offset(self, offset): + return await self.set_offset(ConfigAxis.X, offset) + + async def get_x_offset(self): + return await self.get_offset(ConfigAxis.X) + + async def set_y_offset(self, offset): + return await self.set_offset(ConfigAxis.Y, offset) + + async def get_y_offset(self): + return await self.get_offset(ConfigAxis.Y) + + async def set_z_offset(self, offset): + return await self.set_offset(ConfigAxis.Z, offset) + + async def get_z_offset(self): + return await self.get_offset(ConfigAxis.Z) + + async def set_mirror_offset(self, offset, module: ModuleType = ModuleType.FLUORESCENCE): + """Sets the mirror offset for a given module.""" + return await self.send_command(f"CONFIG OFFSET MIRROR1={offset} MODULE={module.value}") + + async def get_mirror_offset(self, module: ModuleType = ModuleType.FLUORESCENCE): + """Gets the mirror offset for a given module.""" + return await self.send_command(f"?CONFIG OFFSET MIRROR1 MODULE={module.value}") + + async def _get_objective_config( + self, index, param, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + command = f"?CONFIG OBJECTIVE INDEX={index} {param}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + return await self.send_command(command) + + async def _set_objective_config( + self, index, param, value, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + command = f"CONFIG OBJECTIVE INDEX={index} {param}={value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + return await self.send_command(command) + + async def get_objective_magnification(self, index): + return await self._get_objective_config(index, "MAGNIFICATION") + + async def set_objective_magnification(self, index, value): + return await self._set_objective_config(index, "MAGNIFICATION", value) + + async def get_objective_autofocus_offset(self, index): + return await self._get_objective_config(index, "AF_OFFSET") + + async def set_objective_autofocus_offset(self, index, value): + return await self._set_objective_config(index, "AF_OFFSET", value) + + async def get_objective_z_offset(self, index): + return await self._get_objective_config(index, "Z_OFFSET") + + async def set_objective_z_offset(self, index, value): + return await self._set_objective_config(index, "Z_OFFSET", value) + + async def get_objective_brightfield_time(self, index): + return await self._get_objective_config(index, "BF_TIME") + + async def set_objective_brightfield_time(self, index, value): + return await self._set_objective_config(index, "BF_TIME", value) + + async def get_objective_roi_offset_x( + self, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + return await self.send_command( + f"?CONFIG OBJECTIVE AF_ROI_OFFSET_X MODULE={hw_module.value} NUMBER={number}" + ) + + async def set_objective_roi_offset_x( + self, value, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + return await self.send_command( + f"CONFIG OBJECTIVE AF_ROI_OFFSET_X={value} MODULE={hw_module.value} NUMBER={number}" + ) + + async def get_objective_roi_offset_y( + self, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + return await self.send_command( + f"?CONFIG OBJECTIVE AF_ROI_OFFSET_Y MODULE={hw_module.value} NUMBER={number}" + ) + + async def set_objective_roi_offset_y( + self, value, hw_module: ModuleType = ModuleType.FLUORESCENCE_IMAGING, number=1 + ): + return await self.send_command( + f"CONFIG OBJECTIVE AF_ROI_OFFSET_Y={value} MODULE={hw_module.value} NUMBER={number}" + ) + + def _create_target_string( + self, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + target_string = "" + if hwModule: + target_string += f" MODULE={hwModule.value}" + if number is not None: + target_string += f" NUMBER={number}" + if subcomponent: + target_string += f" SUB={subcomponent}" + return target_string + + async def get_dead_time_config( + self, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the dead time configuration.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + return await self.send_command(f"?CONFIG{target_string} DEADTIME") + + async def set_dead_time_config( + self, deadTime, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Sets the dead time configuration.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + return await self.send_command(f"CONFIG{target_string} DEADTIME={deadTime}") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py new file mode 100644 index 0000000000..0aac91a9a8 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py @@ -0,0 +1,415 @@ +import logging +from typing import Optional + +from .base_control import baseControl +from .spark_enums import InstrumentMessageType, ModuleType + + +class DataControl(baseControl): + async def get_programmable_memory_scope( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the programmable memory scope.""" + command = "#DOWNLOAD TYPE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def get_ranges_for_memory_scope( + self, memory_scope, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the ranges for a memory scope.""" + command = "#DOWNLOAD" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" TYPE={memory_scope}" + return await self.send_command(command) + + async def prepare_download( + self, memory_scope, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Prepares the device for download.""" + command = "DOWNLOAD PREPARE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" TYPE={memory_scope}" + return await self.send_command(command) + + async def start_download_block( + self, offset, size, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Starts downloading a block of data.""" + command = "DOWNLOAD BLOCK START" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" OFFSET={offset} SIZE={size}" + return await self.send_command(command) + + async def end_download_block( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Ends the download of a block of data.""" + command = "DOWNLOAD BLOCK END" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_download_sections( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available download sections.""" + command = "#DOWNLOAD SECTION NAME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def start_download_section( + self, section_name, size, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Starts downloading a specific section.""" + command = "DOWNLOAD SECTION START" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" NAME={section_name} SIZE={size}" + return await self.send_command(command) + + async def end_download_section( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Ends the download of a section.""" + command = "DOWNLOAD SECTION END" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def finalize_download( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Finalizes the download process.""" + command = "DOWNLOAD FINISH" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def send_download_data(self, data): + """Sends data to the device during download.""" + raise NotImplementedError + # return await self._usb_write(self.ep_bulk_out, data) + + async def get_command_buffer_size(self): + """Gets the command buffer size.""" + return await self.send_command("?BUFFER COMMAND SIZE") + + async def get_command_overhead(self): + """Gets the command overhead.""" + return await self.send_command("?BUFFER COMMAND OVERHEAD") + + async def clear_error_stack( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Clears the error stack.""" + command = "LASTERROR CLEAR" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_error_index_range( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the error index range.""" + command = "#LASTERROR INDEX" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_max_error_index( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current maximum error index.""" + command = "?LASTERROR MAX" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_error( + self, index, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the error at the given index.""" + command = f"?LASTERROR INDEX={index}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_module_sap_number(self): + """Gets the module SAP number.""" + return await self.send_command("?INFO SAP_NR_MODULE") + + async def get_instrument_sap_number(self): + """Gets the instrument SAP number.""" + return await self.send_command("?INFO SAP_NR_INSTRUMENT") + + async def get_module_sap_serial_number(self): + """Gets the module SAP serial number.""" + return await self.send_command("?SAP_SERIAL_INSTRUMENT SAP_NR_INSTRUMENT") + + async def get_instrument_type(self): + """Gets the instrument type.""" + return await self.send_command("?INFO INSTRUMENT_TYPE") + + async def get_hardware_version( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the hardware version.""" + command = "?INFO HARDWARE_VERSION" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_user_defined(self): + """Gets the user-defined information.""" + return await self.send_command("?INFO USERDEFINED") + + async def get_available_modules(self): + """Gets the list of available modules.""" + return await self.send_command("#MODULE") + + async def get_expected_modules(self): + """Gets the list of expected modules.""" + return await self.send_command("#MODULE EXPECTED") + + async def get_expected_usb_modules(self): + """Gets the list of expected USB modules.""" + return await self.send_command("#MODULE EXPECTED_USB") + + async def get_available_sub_modules( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of available sub-modules.""" + command = "#MODULE SUB" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_expected_sub_modules( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of expected sub-modules.""" + command = "#MODULE EXPECTED_SUB" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_optional_modules(self): + """Gets the list of optional modules.""" + return await self.send_command("#MODULE DYNAMIC") + + async def get_available_functions( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of available functions.""" + command = "#FUNCTION" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_limit_value( + self, name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the integer limit value for a given name.""" + command = "?LIMIT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {name.upper()}" + return await self.send_command(command) + + async def get_double_limit_value( + self, name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the double limit value for a given name.""" + command = "?LIMIT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {name.upper()}" + return await self.send_command(command) + + async def get_available_message_types(self): + """Gets the available message types.""" + response = await self.send_command("#MESSAGE TYPE") + return response + + async def get_interval_range(self, message_type: InstrumentMessageType): + """Gets the interval range for a message type.""" + return await self.send_command(f"#MESSAGE TYPE={message_type.value.upper()} TIME_INTERVAL") + + async def get_current_interval(self, message_type: InstrumentMessageType): + """Gets the current interval for a message type.""" + return await self.send_command(f"?MESSAGE TYPE={message_type.value.upper()} TIME_INTERVAL") + + async def set_interval(self, message_type: InstrumentMessageType, interval): + """Sets the interval for a message type.""" + return await self.send_command( + f"MESSAGE TYPE={message_type.value.upper()} TIME_INTERVAL={interval}" + ) + + async def turn_all_interval_messages_off(self): + """Turns off all interval messages.""" + return await self.send_command("MESSAGE TYPE=ALL TIME_INTERVAL=0") + + def _create_target_string( + self, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + target_string = "" + if hwModule: + target_string += f" MODULE={hwModule.value}" + if number is not None: + target_string += f" NUMBER={number}" + if subcomponent: + target_string += f" SUB={subcomponent}" + return target_string + + async def get_upload_memory_scope( + self, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the upload memory scope.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + response = await self.send_command(f"#UPLOAD TYPE{target_string}") + return response + + async def get_ranges_for_upload_memory_scope( + self, memory_scope, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the ranges for an upload memory scope.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + return await self.send_command(f"#UPLOAD{target_string} TYPE={memory_scope}") + + async def prepare_upload( + self, memory_scope, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Prepares the device for upload.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + # This command uses data channel, which is not fully implemented yet. + logging.warning("Prepare upload uses data channel, not fully implemented.") + return await self.send_command(f"UPLOAD PREPARE{target_string} TYPE={memory_scope}") + + async def upload_block( + self, + offset, + size, + timeout, + hwModule: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Uploads a block of data.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + # This command uses data channel, which is not fully implemented yet. + logging.warning("Upload block uses data channel, not fully implemented.") + return await self.send_command(f"UPLOAD BLOCK{target_string} OFFSET={offset} SIZE={size}") + + async def get_upload_sections( + self, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available upload sections.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + response = await self.send_command(f"#UPLOAD SECTION{target_string} NAME") + return response + + async def upload_section( + self, + section_name, + size, + timeout, + hwModule: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Uploads a specific section.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + # This command uses data channel, which is not fully implemented yet. + logging.warning("Upload section uses data channel, not fully implemented.") + return await self.send_command(f"UPLOAD SECTION{target_string} NAME={section_name} SIZE={size}") + + async def get_upload_section_size( + self, section_name, hwModule: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the size of a specific upload section.""" + target_string = self._create_target_string(hwModule, number, subcomponent) + return await self.send_command(f"?UPLOAD SECTION{target_string} NAME={section_name} SIZE") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py new file mode 100644 index 0000000000..50b41c477d --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py @@ -0,0 +1,69 @@ +from .base_control import baseControl +from .spark_enums import GasOption, GasPowerState + + +class GasControl(baseControl): + async def get_gas_options(self): + """Gets the available gas options.""" + response = await self.send_command("#GASCONTROL GAS") + return response + + async def get_current_gas_concentration(self, gas_option: GasOption): + """Gets the current gas concentration for the given option.""" + return await self.send_command(f"?GASCONTROL GAS={gas_option.value} ACTUAL_CONCENTRATION") + + async def get_gas_target_range(self, gas_option: GasOption): + """Gets the target concentration range for the given gas option.""" + return await self.send_command(f"#GASCONTROL GAS={gas_option.value} RATED_CONCENTRATION") + + async def get_gas_modes(self): + """Gets the available gas control modes.""" + response = await self.send_command("#GASCONTROL MODE") + return response + + async def get_gas_mode(self, gas_option: GasOption): + """Gets the current gas control mode for the given option.""" + return await self.send_command(f"?GASCONTROL GAS={gas_option.value} MODE") + + async def set_gas_mode(self, gas_option: GasOption, mode): + """Sets the gas control mode for the given option.""" + return await self.send_command(f"GASCONTROL GAS={gas_option.value} MODE={mode}") + + async def get_gas_states(self): + """Gets the available gas states.""" + response = await self.send_command("#GASCONTROL STATUS") + return response + + async def get_gas_state(self, gas_option: GasOption): + """Gets the current gas state for the given option.""" + return await self.send_command(f"?GASCONTROL GAS={gas_option.value} STATUS") + + async def get_gas_target_concentration(self, gas_option: GasOption): + """Gets the target gas concentration for the given option.""" + return await self.send_command(f"?GASCONTROL GAS={gas_option.value} RATED_CONCENTRATION") + + async def set_gas_target_concentration(self, gas_option: GasOption, target): + """Sets the target gas concentration for the given option.""" + return await self.send_command( + f"GASCONTROL GAS={gas_option.value} RATED_CONCENTRATION={target}" + ) + + async def set_gas_sensor_power(self, state: GasPowerState): + """Sets the gas sensor power state (True for ON, False for OFF).""" + return await self.send_command(f"GASCONTROL POWER={state.value}") + + async def acknowledge_audio_gas_warning(self): + """Acknowledges the audio gas warning, turning off the buzzer.""" + return await self.send_command("GASCONTROL BUZZER=OFF") + + async def get_altitude(self): + """Gets the current altitude setting for gas control.""" + return await self.send_command("?GASCONTROL ALTITUDE") + + async def set_altitude(self, altitude): + """Sets the altitude for gas control.""" + return await self.send_command(f"GASCONTROL ALTITUDE={altitude}") + + async def get_altitude_range(self): + """Gets the allowed altitude range for gas control.""" + return await self.send_command("#GASCONTROL ALTITUDE") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py new file mode 100644 index 0000000000..a42e9eea8a --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py @@ -0,0 +1,133 @@ +from typing import List + +from .base_control import baseControl +from .spark_enums import InjectionMode, InjectorName, InjectorState + + +class InjectorControl(baseControl): + async def get_all_injectors(self): + """Gets all available injectors.""" + response = await self.send_command("#INJECTOR PUMP") + return response + + async def get_injector_volume_range(self, pump: InjectorName, mode: InjectionMode): + """Gets the injector volume range.""" + return await self.send_command(f"#INJECTOR PUMP={pump.value} MODE={mode.value} VOLUME") + + async def get_injector_speed_range(self, pump: InjectorName, mode: InjectionMode): + """Gets the injector speed range.""" + return await self.send_command(f"#INJECTOR PUMP={pump.value} MODE={mode.value} SPEED") + + async def get_injector_well_diameter_range(self, pump: InjectorName): + """Gets the injector well diameter range.""" + return await self.send_command(f"#INJECTOR PUMP={pump.value} WELLDIAMETER") + + async def get_injector_position_x(self, pump: InjectorName): + """Gets the injector X position.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} POSITIONX") + + async def get_injector_position_y(self, pump: InjectorName): + """Gets the injector Y position.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} POSITIONY") + + async def get_defined_injector_volume(self, pump: InjectorName, mode: InjectionMode): + """Gets the defined injector volume.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} MODE={mode.value} VOLUME") + + async def get_injector_default_volume(self, pump: InjectorName, mode: InjectionMode): + """Gets the injector default volume.""" + return await self.send_command(f"?INJECTOR DEFAULT PUMP={pump.value} MODE={mode.value} VOLUME") + + async def set_injector_volume(self, pump: InjectorName, mode: InjectionMode, volume): + """Sets the injector volume.""" + return await self.send_command(f"INJECTOR PUMP={pump.value} MODE={mode.value} VOLUME={volume}") + + async def set_injector_default_volume(self, pump: InjectorName, mode: InjectionMode, volume): + """Sets the injector default volume.""" + return await self.send_command( + f"INJECTOR DEFAULT PUMP={pump.value} MODE={mode.value} VOLUME={volume}" + ) + + async def get_defined_injector_speed(self, pump: InjectorName, mode: InjectionMode): + """Gets the defined injector speed.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} MODE={mode.value} SPEED") + + async def get_injector_default_speed(self, pump: InjectorName, mode: InjectionMode): + """Gets the injector default speed.""" + return await self.send_command(f"?INJECTOR DEFAULT PUMP={pump.value} MODE={mode.value} SPEED") + + async def set_injector_speed(self, pump: InjectorName, mode: InjectionMode, speed): + """Sets the injector speed.""" + return await self.send_command(f"INJECTOR PUMP={pump.value} MODE={mode.value} SPEED={speed}") + + async def set_injector_default_speed(self, pump: InjectorName, mode: InjectionMode, speed): + """Sets the injector default speed.""" + return await self.send_command( + f"INJECTOR DEFAULT PUMP={pump.value} MODE={mode.value} SPEED={speed}" + ) + + async def get_injector_model(self, pump: InjectorName): + """Gets the injector model.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} MODEL") + + async def set_injector_state(self, state: InjectorState, pumps: List[InjectorName]): + """Sets the state of the specified injector(s).""" + pumps_str = "|".join([p.value for p in pumps]) + return await self.send_command(f"INJECTOR STATE={state.value} PUMP={pumps_str}") + + async def set_injector_refill_mode(self, pump: InjectorName, mode): + """Sets the injector refill mode.""" + return await self.send_command(f"INJECTOR REFILL TYPE={mode.upper()} PUMP={pump.value}") + + async def is_injector_primed(self, pump: InjectorName): + """Checks if the injector is primed.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} PRIMED") + + async def injector_start_injecting(self, mode: InjectionMode): + """Starts the injection process in the specified mode (DISPENSE, PRIME, RINSE, BACKFLUSH).""" + return await self.send_command( + f"INJECTOR {mode.value}", + ) + + async def injector_dispense(self): + """Alias for injector_start_injecting('DISPENSE').""" + return await self.injector_start_injecting(InjectionMode.DISPENSE) + + async def injector_prime(self): + """Alias for injector_start_injecting('PRIME').""" + return await self.injector_start_injecting(InjectionMode.PRIME) + + async def injector_rinse(self): + """Alias for injector_start_injecting('RINSE').""" + return await self.injector_start_injecting(InjectionMode.RINSE) + + async def injector_backflush(self): + """Alias for injector_start_injecting('BACKFLUSH').""" + return await self.injector_start_injecting(InjectionMode.BACKFLUSH) + + async def get_injector_syringe_volume(self, pump: InjectorName): + """Gets the injector syringe volume.""" + return await self.send_command(f"?INJECTOR PUMP={pump.value} SYRINGEVOLUME") + + async def deactivate_all_injectors(self): + """Deactivates all injectors.""" + all_injectors_str = await self.get_all_injectors() + if all_injectors_str: + parts = all_injectors_str.split("|") + injectors = [] + for p in parts: + try: + injectors.append(InjectorName(p)) + except ValueError: + pass # Ignore unknown pumps + if injectors: + return await self.set_injector_state(InjectorState.INACTIVE, injectors) + return None + + async def activate_injectors(self, injectors: List[InjectorName]): + """Activates the specified injectors.""" + return await self.set_injector_state(InjectorState.ACTIVE, injectors) + + async def fim_raise_trigger(self): + """Raises the FIM camera trigger.""" + return await self.send_command("SCAN") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py new file mode 100644 index 0000000000..45f18da573 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py @@ -0,0 +1,384 @@ +import logging +from enum import Enum +from typing import Optional + +from .base_control import baseControl +from .spark_enums import MeasurementMode, ModuleType + + +class ScanDirection(Enum): + UP = "UP" + DOWN = "DOWN" + ALTERNATE_UP = "ALTERNATE_UP" + ALTERNATE_DOWN = "ALTERNATE_DOWN" + + +class ScanDarkState(Enum): + TRUE = "TRUE" + FALSE = "FALSE" + + +class measurement_control(baseControl): + """ + This class provides methods for controlling measurement operations on the device. + It includes functionalities to start/end measurements, set/get measurement modes, + prepare the instrument, and perform various types of scans. + """ + + async def start_measurement(self): + """Starts the measurement process.""" + return await self.send_command("MEASUREMENT START") + + async def end_measurement(self): + """Ends the measurement process.""" + return await self.send_command("MEASUREMENT END") + + async def set_measurement_mode(self, mode: MeasurementMode): + """Sets the measurement mode (e.g., ABS, LUM, FLUOR).""" + return await self.send_command(f"MODE MEASUREMENT={mode.value}") + + async def get_available_measurement_modes(self): + """Gets the available measurement modes.""" + response = await self.send_command("#MODE MEASUREMENT") + return response + + async def get_current_measurement_mode(self): + """Gets the current measurement mode.""" + return await self.send_command("?MODE MEASUREMENT") + + async def get_label_range(self): + """Gets the range of available labels for scans.""" + return await self.send_command("#SCAN LABEL") + + async def prepare_instrument( + self, measure_reference=True, mode: Optional[MeasurementMode] = None, labels=None + ): + """Prepares the instrument for measurement.""" + command = f"PREPARE REFERENCE={'YES' if measure_reference else 'NO'}" + if mode and labels: + command += f" MODE={mode.value} LABEL={'|'.join(map(str, labels))}" + return await self.send_command(command) + + async def set_scan_direction(self, direction: ScanDirection): + """Sets the scan direction.""" + return await self.send_command(f"SCAN DIRECTION={direction.value}") + + async def get_available_scan_directions(self): + """Gets the available scan directions.""" + response = await self.send_command("#SCAN DIRECTION") + return response + + async def get_current_scan_direction(self): + """Gets the current scan direction.""" + return await self.send_command("?SCAN DIRECTION") + + def _format_scan_range(self, coordinate, from_val, to_val, step_type_is_delta, steps): + if all(v is not None for v in [coordinate, from_val, to_val, step_type_is_delta, steps]): + step_char = ":" if step_type_is_delta else "%" + return f" {coordinate}={from_val}~{to_val}{step_char}{steps}" + return "" + + async def _measure_in( + self, + scale, + from_val=None, + to_val=None, + x=None, + y=None, + z=None, + steps=None, + step_type_is_delta=None, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + command = "SCAN" + if scale == "X": + command += self._format_scan_range("X", from_val, to_val, step_type_is_delta, steps) + if y is not None: + command += f" Y={y}" + if z is not None: + command += f" Z={z}" + elif scale == "Y": + if x is not None: + command += f" X={x}" + command += self._format_scan_range("Y", from_val, to_val, step_type_is_delta, steps) + if z is not None: + command += f" Z={z}" + elif scale == "Z": + if x is not None: + command += f" X={x}" + if y is not None: + command += f" Y={y}" + command += self._format_scan_range("Z", from_val, to_val, step_type_is_delta, steps) + elif scale == "T": + if x is not None: + command += f" X={x}" + if y is not None: + command += f" Y={y}" + if z is not None: + command += f" Z={z}" + command += self._format_scan_range("T", from_val, to_val, step_type_is_delta, steps) + else: # None + if x is not None: + command += f" X={x}" + if y is not None: + command += f" Y={y}" + if z is not None: + command += f" Z={z}" + + if mode and labels: + command += f" MODE={mode.value} LABEL={'|'.join(map(str, labels))}" + + return await self.send_command(command) + + async def measure_range_in_x_pointwise( + self, + from_x, + to_x, + position_y, + position_z, + num_points, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "X", + from_x, + to_x, + y=position_y, + z=position_z, + steps=num_points, + step_type_is_delta=False, + mode=mode, + labels=labels, + ) + + async def measure_range_in_x_by_delta( + self, + from_x, + to_x, + position_y, + position_z, + delta, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "X", + from_x, + to_x, + y=position_y, + z=position_z, + steps=delta, + step_type_is_delta=True, + mode=mode, + labels=labels, + ) + + async def measure_range_in_y_pointwise( + self, + position_x, + from_y, + to_y, + position_z, + num_points, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "Y", + from_y, + to_y, + x=position_x, + z=position_z, + steps=num_points, + step_type_is_delta=False, + mode=mode, + labels=labels, + ) + + async def measure_range_in_y_by_delta( + self, + position_x, + from_y, + to_y, + position_z, + delta, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "Y", + from_y, + to_y, + x=position_x, + z=position_z, + steps=delta, + step_type_is_delta=True, + mode=mode, + labels=labels, + ) + + async def measure_range_in_z_pointwise( + self, + position_x, + position_y, + from_z, + to_z, + num_points, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "Z", + from_z, + to_z, + x=position_x, + y=position_y, + steps=num_points, + step_type_is_delta=False, + mode=mode, + labels=labels, + ) + + async def measure_range_in_z_by_delta( + self, + position_x, + position_y, + from_z, + to_z, + delta, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "Z", + from_z, + to_z, + x=position_x, + y=position_y, + steps=delta, + step_type_is_delta=True, + mode=mode, + labels=labels, + ) + + async def measure_range_in_t_pointwise( + self, + position_x, + position_y, + position_z, + from_t, + to_t, + num_points, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "T", + from_t, + to_t, + x=position_x, + y=position_y, + z=position_z, + steps=num_points, + step_type_is_delta=False, + mode=mode, + labels=labels, + ) + + async def measure_range_in_t_by_delta( + self, + position_x, + position_y, + position_z, + from_t, + to_t, + delta, + mode: Optional[MeasurementMode] = None, + labels=None, + ): + return await self._measure_in( + "T", + from_t, + to_t, + x=position_x, + y=position_y, + z=position_z, + steps=delta, + step_type_is_delta=True, + mode=mode, + labels=labels, + ) + + async def measure_position( + self, x=None, y=None, z=None, mode: Optional[MeasurementMode] = None, labels=None + ): + return await self._measure_in(None, x=x, y=y, z=z, mode=mode, labels=labels) + + async def measure_current_position(self, mode: Optional[MeasurementMode] = None, labels=None): + return await self._measure_in(None, mode=mode, labels=labels) + + async def ensure_measurement_mode(self, mode: MeasurementMode): + """Ensures the measurement mode is set, only if different from current.""" + current_mode = await self.get_current_measurement_mode() + if current_mode != f"MEASUREMENT={mode.value.upper()}": + return await self.set_measurement_mode(mode) + logging.info(f"Measurement Mode already set to: {mode.value}") + return "OK" + + async def set_scan_dark(self, state: ScanDarkState, module: ModuleType = ModuleType.FLUORESCENCE): + """Sets the scan dark state for a module.""" + return await self.send_command(f"SCAN DARK={state.value} MODULE={module.value}") + + async def set_parallel_excitation_polarisation(self): + """Sets the excitation polarisation to parallel.""" + return await self.send_command("POLARISATION EXCITATION=PARALLEL") + + async def set_perpendicular_excitation_polarisation(self): + """Sets the excitation polarisation to perpendicular.""" + return await self.send_command("POLARISATION EXCITATION=PERPENDICULAR") + + async def set_parallel_emission_polarisation(self): + """Sets the emission polarisation to parallel.""" + return await self.send_command("POLARISATION EMISSION=PARALLEL") + + async def set_perpendicular_emission_polarisation(self): + """Sets the emission polarisation to perpendicular.""" + return await self.send_command("POLARISATION EMISSION=PERPENDICULAR") + + async def set_polarisation_emission_mode(self, mode): + """Sets the polarisation emission mode.""" + return await self.send_command(f"POLARISATION MODE_EMISSION={mode.upper()}") + + async def get_possible_number_of_reads_range(self): + """Gets the possible range for the number of reads.""" + return await self.send_command("#READ COUNT") + + async def set_number_of_reads(self, count, label=None): + """Sets the number of reads.""" + command = "READ" + if label is not None: + command += f" LABEL={label}" + command += f" COUNT={count}" + return await self.send_command(command) + + async def get_current_number_of_reads(self, label=None): + """Gets the current number of reads.""" + command = "?READ" + if label is not None: + command += f" LABEL={label}" + command += " COUNT" + return await self.send_command(command) + + async def get_allowed_read_speed_range(self): + """Gets the allowed range for read speed.""" + return await self.send_command("#READ SPEED") + + async def set_read_speed(self, speed): + """Sets the read speed.""" + return await self.send_command(f"READ SPEED={speed}") + + async def get_current_read_speed(self): + """Gets the current read speed.""" + return await self.send_command("?READ SPEED") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py new file mode 100644 index 0000000000..7ad143186e --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py @@ -0,0 +1,708 @@ +from enum import Enum +from typing import Optional + +from .base_control import baseControl +from .spark_enums import ModuleType, ShakingMode, ShakingName + + +class LidLiftState(Enum): + ON = "ON" + OFF = "OFF" + + +class RetractionState(Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class movement_control(baseControl): + """ + This class provides methods for controlling various movement operations on the device. + It includes functionalities to move motors to absolute or relative positions, + manage motor configurations, and handle micro-stepping. + """ + + async def move_motors( + self, motor_values, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Moves motors to absolute positions.""" + command = "ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + for key, value in motor_values.items(): + command += f" {key}={value}" + return await self.send_command(command) + + async def move_to_named_position( + self, position, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Moves to a predefined named position.""" + command = "ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" POSITION={position}" + return await self.send_command(command) + + async def get_movement_motors( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of available motors for movement.""" + command = "#ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def get_motor_movement_range( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the movement range for a specific motor.""" + command = "#ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {motor}" + return await self.send_command(command) + + async def get_available_movement_positions( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of available predefined movement positions.""" + command = "#ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += " POSITION" + response = await self.send_command(command) + return response + + async def get_current_module_motor_values( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current motor values for the module.""" + command = "?ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_motor_value( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current value of a specific motor.""" + command = "?ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {motor}" + return await self.send_command(command) + + async def get_current_movement_position( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current predefined movement position.""" + command = "?ABSOLUTE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += " POSITION" + return await self.send_command(command) + + async def _get_movement_config_int( + self, motor, option, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + command = "?CONFIG MOVEMENT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" MOTOR={motor} {option}" + return await self.send_command(command) + + async def _set_movement_config_int( + self, + motor, + option, + value, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + command = "CONFIG MOVEMENT" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" MOTOR={motor} {option}={value}" + return await self.send_command(command) + + async def get_motor_start_frequency( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "STARTFREQUENCY", hw_module, number, subcomponent + ) + + async def set_motor_start_frequency( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "STARTFREQUENCY", value, hw_module, number, subcomponent + ) + + async def get_motor_end_frequency( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "ENDFREQUENCY", hw_module, number, subcomponent + ) + + async def set_motor_end_frequency( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "ENDFREQUENCY", value, hw_module, number, subcomponent + ) + + async def get_motor_ramp_step( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "RAMPSTEP", hw_module, number, subcomponent) + + async def set_motor_ramp_step( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "RAMPSTEP", value, hw_module, number, subcomponent + ) + + async def get_motor_step_loss( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "STEPLOSS", hw_module, number, subcomponent) + + async def set_motor_step_loss( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "STEPLOSS", value, hw_module, number, subcomponent + ) + + async def get_motor_micro_stepping( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "MICROSTEPPING", hw_module, number, subcomponent + ) + + async def set_motor_micro_stepping( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "MICROSTEPPING", value, hw_module, number, subcomponent + ) + + async def get_motor_resolution( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "RESOLUTION", hw_module, number, subcomponent) + + async def set_motor_resolution( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "RESOLUTION", value, hw_module, number, subcomponent + ) + + async def get_motor_operating_current( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "CURRENT", hw_module, number, subcomponent) + + async def set_motor_operating_current( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "CURRENT", value, hw_module, number, subcomponent + ) + + async def get_motor_standby_current( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "STANDBYCURRENT", hw_module, number, subcomponent + ) + + async def set_motor_standby_current( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "STANDBYCURRENT", value, hw_module, number, subcomponent + ) + + async def get_motor_min_travel( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "MINPOS", hw_module, number, subcomponent) + + async def set_motor_min_travel( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "MINPOS", value, hw_module, number, subcomponent + ) + + async def get_motor_max_travel( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "MAXPOS", hw_module, number, subcomponent) + + async def set_motor_max_travel( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "MAXPOS", value, hw_module, number, subcomponent + ) + + async def get_motor_positive_direction( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "POSITIVEDIRECTION", hw_module, number, subcomponent + ) + + async def set_motor_positive_direction( + self, motor, direction, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "POSITIVEDIRECTION", direction.upper(), hw_module, number, subcomponent + ) + + async def get_motor_sense_resistor( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int( + motor, "SENSERESISTOR", hw_module, number, subcomponent + ) + + async def set_motor_sense_resistor( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "SENSERESISTOR", value, hw_module, number, subcomponent + ) + + async def get_motor_ramp_scale( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._get_movement_config_int(motor, "RAMPSCALE", hw_module, number, subcomponent) + + async def set_motor_ramp_scale( + self, motor, value, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + return await self._set_movement_config_int( + motor, "RAMPSCALE", value, hw_module, number, subcomponent + ) + + async def move_micro_step( + self, + relative, + motor_parameters, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Moves motors by micro steps, absolute or relative.""" + step_type = "STEPREL" if relative else "STEPABS" + command = f"{step_type}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + for key, value in motor_parameters.items(): + command += f" {key}={value}" + return await self.send_command(command) + + async def get_micro_step_motors( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of motors supporting micro stepping.""" + command = "#STEPABS" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def get_absolute_micro_step_motor_range( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the absolute micro step range for a motor.""" + command = "#STEPABS" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {motor}" + return await self.send_command(command) + + async def get_current_absolute_micro_step_values( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current absolute micro step values for all motors.""" + command = "?STEPABS" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_absolute_micro_step_value( + self, motor, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current absolute micro step value for a specific motor.""" + command = "?STEPABS" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {motor}" + return await self.send_command(command) + + async def move_carrier( + self, carrier, position, hw_module: Optional[ModuleType] = None, number=None + ): + """Moves a carrier to a specific position.""" + command = "MOVE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + command += f" CARRIER={carrier} POSITION={position}" + return await self.send_command(command) + + async def get_position_config_name(self, index): + """Gets the name of the position configuration at the given index.""" + return await self.send_command(f"?CONFIG ABSOLUTE POSITION INDEX={index} NAME") + + async def set_position_config_name(self, index, name): + """Sets the name of the position configuration at the given index.""" + return await self.send_command(f"CONFIG ABSOLUTE POSITION INDEX={index} NAME={name}") + + async def get_position_config_x(self, index): + """Gets the X coordinate of the position configuration at the given index.""" + return await self.send_command(f"?CONFIG ABSOLUTE POSITION INDEX={index} X") + + async def set_position_config_x(self, index, x): + """Sets the X coordinate of the position configuration at the given index.""" + return await self.send_command(f"CONFIG ABSOLUTE POSITION INDEX={index} X={x}") + + async def get_position_config_y(self, index): + """Gets the Y coordinate of the position configuration at the given index.""" + return await self.send_command(f"?CONFIG ABSOLUTE POSITION INDEX={index} Y") + + async def set_position_config_y(self, index, y): + """Sets the Y coordinate of the position configuration at the given index.""" + return await self.send_command(f"CONFIG ABSOLUTE POSITION INDEX={index} Y={y}") + + async def get_position_config_z(self, index): + """Gets the Z coordinate of the position configuration at the given index.""" + return await self.send_command(f"?CONFIG ABSOLUTE POSITION INDEX={index} Z") + + async def set_position_config_z(self, index, z): + """Sets the Z coordinate of the position configuration at the given index.""" + return await self.send_command(f"CONFIG ABSOLUTE POSITION INDEX={index} Z={z}") + + async def get_maximum_position_indexes(self): + """Gets the maximum number of position configuration indexes.""" + return await self.send_command("CONFIG ABSOLUTE POSITION MAXINDEX") + + async def activate_retraction(self, retractable_element): + """Activates the automatic retraction for the given element.""" + return await self.set_retraction_state(retractable_element, RetractionState.ENABLED) + + async def deactivate_retraction(self, retractable_element): + """Deactivates the automatic retraction for the given element.""" + return await self.set_retraction_state(retractable_element, RetractionState.DISABLED) + + async def set_retraction_state(self, retractable_element, state: RetractionState): + """Sets the retraction state for the given element.""" + return await self.send_command(f"AUTO_IN {retractable_element}={state.value}") + + async def is_retraction_active(self, retractable_element): + """Checks if automatic retraction is active for the given element.""" + response = await self.send_command(f"?AUTO_IN {retractable_element}") + return response == f"{retractable_element}={RetractionState.ENABLED.value}" + + async def is_retraction_inactive(self, retractable_element): + """Checks if automatic retraction is inactive for the given element.""" + response = await self.send_command(f"?AUTO_IN {retractable_element}") + return response == f"{retractable_element}={RetractionState.DISABLED.value}" + + async def get_lid_lift_states(self): + """Gets the available lid lift states.""" + response = await self.send_command("#LIDLIFT STATE") + return response + + async def get_plate_height_range(self): + """Gets the allowed range for plate height.""" + return await self.send_command("#LIDLIFT PLATEHEIGHT") + + async def set_lid_lifter_state(self, state: LidLiftState, plate_height): + """Sets the lid lifter state and plate height.""" + return await self.send_command(f"LIDLIFT STATE={state.value} PLATEHEIGHT={plate_height}") + + async def get_lid_lifter_current_state(self): + """Gets the current state of the lid lifter.""" + return await self.send_command("?LIDLIFT STATE") + + async def injector_move_to_position(self, plate_height, x=None, y=None): + """Moves the injector to the specified position.""" + command = f"ABSOLUTE INJECTOR PLATEHEIGHT={plate_height}" + if x is not None: + command += f" X={x}" + if y is not None: + command += f" Y={y}" + return await self.send_command(command) + + async def get_available_shaking_modes( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available shaking modes.""" + command = "#MODE SHAKING" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_available_shaking_amplitudes( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available shaking amplitudes.""" + command = "#SHAKING AMPLITUDE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_available_shaking_frequencies( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available shaking frequencies.""" + command = "#SHAKING FREQUENCY" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_available_shaking_names( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available shaking names.""" + command = "#SHAKING NAME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_available_shaking_time_span( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the available shaking time span.""" + command = "#SHAKING TIME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def start_shaking( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Starts the shaking.""" + command = "SHAKING START" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_shaking_name( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current shaking name.""" + command = "?SHAKING NAME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_shaking_frequency( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current shaking frequency.""" + command = "?SHAKING FREQUENCY" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_shaking_amplitude( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current shaking amplitude.""" + command = "?SHAKING AMPLITUDE" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_shaking_time( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current shaking time.""" + command = "?SHAKING TIME" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_current_shaking_mode( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the current shaking mode.""" + command = "?MODE SHAKING" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def set_shaking_mode( + self, mode: ShakingMode, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Sets the shaking mode.""" + command = f"MODE SHAKING={mode.value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def set_shaking_time( + self, time, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Sets the shaking time.""" + command = f"SHAKING TIME={time}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def set_shaking_by_name( + self, name: ShakingName, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Sets the shaking by name.""" + command = f"SHAKING NAME={name.value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def set_shaking_amplitude_and_frequency( + self, + amplitude, + frequency, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Sets the shaking amplitude and frequency.""" + command = f"SHAKING AMPLITUDE={amplitude} FREQUENCY={frequency}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py new file mode 100644 index 0000000000..c94ad64f99 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py @@ -0,0 +1,708 @@ +from enum import Enum +from typing import Optional + +from .base_control import baseControl +from .spark_enums import ( + FilterType, + FluorescenceCarrier, + MirrorType, + ModuleType, +) + + +class MirrorCarrier(Enum): + MIRROR1 = "MIRROR1" + + +class LaserPowerState(Enum): + ON = "ON" + OFF = "OFF" + + +class LightingState(Enum): + ON = "ON" + OFF = "OFF" + + +class OpticsControl(baseControl): + async def get_beam_diameter_list(self): + """Gets the list of possible beam diameters.""" + return await self.send_command("#BEAM DIAMETER") + + async def set_beam_diameter(self, value): + """Sets the beam diameter.""" + return await self.send_command(f"BEAM DIAMETER={value}") + + async def get_current_beam_diameter(self): + """Gets the current beam diameter.""" + return await self.send_command("?BEAM DIAMETER") + + async def get_emission_carrier_list(self): + """Gets the list of emission carriers.""" + return await self.send_command("#EMISSION CARRIER") + + async def set_emission_carrier(self, carrier: FluorescenceCarrier): + """Sets the emission carrier.""" + return await self.send_command(f"EMISSION CARRIER={carrier.value}") + + async def get_emission_filter_type_list(self, carrier_name: Optional[FluorescenceCarrier] = None): + """Gets the list of emission filter types.""" + command = "#EMISSION TYPE" + if carrier_name: + command = f"#EMISSION CARRIER={carrier_name.value} TYPE" + response = await self.send_command(command) + return response + + async def get_emission_filter_wavelength_list( + self, + carrier_name: Optional[FluorescenceCarrier] = None, + module: Optional[ModuleType] = None, + sub_module=None, + ): + """Gets the list of emission filter wavelengths.""" + command = "#EMISSION WAVELENGTH" + if carrier_name: + command = f"#EMISSION CARRIER={carrier_name.value} WAVELENGTH" + if module: + command += f" MODULE={module.value}" + if sub_module: + command += f" SUB={sub_module}" + return await self.send_command(command) + + async def get_emission_filter_bandwidth_list( + self, carrier_name: Optional[FluorescenceCarrier] = None + ): + """Gets the list of emission filter bandwidths.""" + command = "#EMISSION BANDWIDTH" + if carrier_name: + command = f"#EMISSION CARRIER={carrier_name.value} BANDWIDTH" + return await self.send_command(command) + + async def get_emission_filter_attenuation_list( + self, carrier_name: Optional[FluorescenceCarrier] = None + ): + """Gets the list of emission filter attenuations.""" + command = "#EMISSION ATTENUATION" + if carrier_name: + command = f"#EMISSION CARRIER={carrier_name.value} ATTENUATION" + return await self.send_command(command) + + async def get_current_emission_filter( + self, label=None, carrier: Optional[FluorescenceCarrier] = None + ): + """Gets the current emission filter.""" + command = "?EMISSION" + if carrier: + command += f" CARRIER={carrier.value}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_emission_filter_descriptions(self, carrier: FluorescenceCarrier): + """Gets the emission filter descriptions.""" + return await self.send_command(f"#EMISSION CARRIER={carrier.value} DESCRIPTION") + + async def get_emission_flash_counters(self, carrier: FluorescenceCarrier): + """Gets the emission flash counters.""" + return await self.send_command(f"#EMISSION CARRIER={carrier.value} FLASH_COUNTER") + + async def get_emission_filter_slide_usage(self, carrier_name: FluorescenceCarrier): + """Gets the emission filter slide usage.""" + return await self.send_command(f"?EMISSION CARRIER={carrier_name.value} SLIDE_USAGE") + + async def get_emission_filter_slide_description(self, carrier_name: FluorescenceCarrier): + """Gets the emission filter slide description.""" + return await self.send_command(f"?EMISSION CARRIER={carrier_name.value} SLIDE_DESCRIPTION") + + async def set_emission_filter( + self, + filter_type: FilterType, + wavelength=None, + bandwidth=None, + attenuation=None, + label=None, + carrier: Optional[FluorescenceCarrier] = None, + ): + """Sets the emission filter.""" + command = "EMISSION" + if carrier: + command += f" CARRIER={carrier.value}" + if filter_type: + command += f" TYPE={filter_type.value}" + if wavelength is not None: + command += f" WAVELENGTH={wavelength}" + if bandwidth is not None: + command += f" BANDWIDTH={bandwidth}" + if attenuation is not None: + command += f" ATTENUATION={attenuation}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_emission_empty_position_wavelength_limit_list(self): + """Gets the empty position wavelength limit list for emission.""" + return await self.send_command("#EMISSION WAVELENGTH TYPE=EMPTY") + + async def get_empty_position_wavelength_limit_lower( + self, hw_module: Optional[ModuleType] = None, number=None + ): + """Gets the lower wavelength limit for the empty position in the emission filter.""" + command = "?CONFIG FILTER=EMISSION TYPE=EMPTY" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + return await self.send_command(command) + + async def get_empty_position_wavelength_limit_upper( + self, hw_module: Optional[ModuleType] = None, number=None + ): + """Gets the upper wavelength limit for the empty position in the emission filter.""" + command = "?CONFIG FILTER=EMISSION TYPE=EMPTY" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + return await self.send_command(command) + + async def set_empty_position_wavelength_limits( + self, lower, upper, hw_module: Optional[ModuleType] = None, number=None + ): + """Sets the wavelength limits for the empty position in the emission filter.""" + command = f"CONFIG FILTER=EMISSION TYPE=EMPTY LOWERWAVELENGTH={lower} UPPERWAVELENGTH={upper}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + return await self.send_command(command) + + async def get_excitation_carrier_list(self): + """Gets the list of excitation carriers.""" + return await self.send_command("#EXCITATION CARRIER") + + async def set_excitation_carrier(self, carrier: FluorescenceCarrier): + """Sets the excitation carrier.""" + return await self.send_command(f"EXCITATION CARRIER={carrier.value}") + + async def get_excitation_filter_type_list( + self, carrier_name: Optional[FluorescenceCarrier] = None + ): + """Gets the list of excitation filter types.""" + command = "#EXCITATION TYPE" + if carrier_name: + command = f"#EXCITATION CARRIER={carrier_name.value} TYPE" + response = await self.send_command(command) + return response + + async def get_excitation_filter_wavelength_list( + self, + carrier_name: Optional[FluorescenceCarrier] = None, + module: Optional[ModuleType] = None, + sub_module=None, + ): + """Gets the list of excitation filter wavelengths.""" + command = "#EXCITATION WAVELENGTH" + if carrier_name: + command = f"#EXCITATION CARRIER={carrier_name.value} WAVELENGTH" + if module: + command += f" MODULE={module.value}" + if sub_module: + command += f" SUB={sub_module}" + return await self.send_command(command) + + async def get_excitation_filter_bandwidth_list( + self, carrier_name: Optional[FluorescenceCarrier] = None + ): + """Gets the list of excitation filter bandwidths.""" + command = "#EXCITATION BANDWIDTH" + if carrier_name: + command = f"#EXCITATION CARRIER={carrier_name.value} BANDWIDTH" + return await self.send_command(command) + + async def get_excitation_filter_attenuation_list( + self, carrier_name: Optional[FluorescenceCarrier] = None + ): + """Gets the list of excitation filter attenuations.""" + command = "#EXCITATION ATTENUATION" + if carrier_name: + command = f"#EXCITATION CARRIER={carrier_name.value} ATTENUATION" + return await self.send_command(command) + + async def get_current_excitation_filter( + self, label=None, carrier: Optional[FluorescenceCarrier] = None + ): + """Gets the current excitation filter.""" + command = "?EXCITATION" + if carrier: + command += f" CARRIER={carrier.value}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_excitation_filter_descriptions(self, carrier: FluorescenceCarrier): + """Gets the excitation filter descriptions.""" + return await self.send_command(f"#EXCITATION CARRIER={carrier.value} DESCRIPTION") + + async def get_excitation_flash_counters(self, carrier: FluorescenceCarrier): + """Gets the excitation flash counters.""" + return await self.send_command(f"#EXCITATION CARRIER={carrier.value} FLASH_COUNTER") + + async def get_excitation_filter_slide_usage(self, carrier_name: FluorescenceCarrier): + """Gets the excitation filter slide usage.""" + return await self.send_command(f"?EXCITATION CARRIER={carrier_name.value} SLIDE_USAGE") + + async def get_excitation_filter_slide_description(self, carrier_name: FluorescenceCarrier): + """Gets the excitation filter slide description.""" + return await self.send_command(f"?EXCITATION CARRIER={carrier_name.value} SLIDE_DESCRIPTION") + + async def set_excitation_filter( + self, + filter_type: FilterType, + wavelength=None, + bandwidth=None, + attenuation=None, + label=None, + carrier: Optional[FluorescenceCarrier] = None, + ): + """Sets the excitation filter.""" + command = "EXCITATION" + if carrier: + command += f" CARRIER={carrier.value}" + if filter_type: + command += f" TYPE={filter_type.value}" + if wavelength is not None: + command += f" WAVELENGTH={wavelength}" + if bandwidth is not None: + command += f" BANDWIDTH={bandwidth}" + if attenuation is not None: + command += f" ATTENUATION={attenuation}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_excitation_empty_position_wavelength_limit_list(self): + """Gets the empty position wavelength limit list for excitation.""" + return await self.send_command("#EXCITATION WAVELENGTH TYPE=EMPTY") + + async def define_filter_read(self, name): + """Reads the filter definition.""" + return await self.send_command(f"DEFINE FILTER READ NAME={name}") + + async def define_filter_write(self, name): + """Writes the filter definition.""" + return await self.send_command(f"DEFINE FILTER WRITE NAME={name}") + + async def get_defined_filter_wavelength(self, name, position): + """Gets the wavelength of a defined filter at a specific position.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} POSITION={position} WAVELENGTH") + + async def set_defined_filter_wavelength(self, name, position, wavelength): + """Sets the wavelength of a defined filter at a specific position.""" + return await self.send_command( + f"DEFINE FILTER NAME={name} POSITION={position} WAVELENGTH={wavelength}" + ) + + async def get_defined_filter_bandwidth(self, name, position): + """Gets the bandwidth of a defined filter at a specific position.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} POSITION={position} BANDWIDTH") + + async def set_defined_filter_bandwidth(self, name, position, bandwidth): + """Sets the bandwidth of a defined filter at a specific position.""" + return await self.send_command( + f"DEFINE FILTER NAME={name} POSITION={position} BANDWIDTH={bandwidth}" + ) + + async def get_defined_filter_type(self, name, position): + """Gets the type of a defined filter at a specific position.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} POSITION={position} TYPE") + + async def set_defined_filter_type(self, name, position, filter_type): + """Sets the type of a defined filter at a specific position.""" + return await self.send_command( + f"DEFINE FILTER NAME={name} POSITION={position} TYPE={filter_type}" + ) + + async def get_defined_filter_flash_counter(self, name, position): + """Gets the flash counter of a defined filter at a specific position.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} POSITION={position} FLASH_COUNTER") + + async def set_defined_filter_flash_counter(self, name, position, flash_counter): + """Sets the flash counter of a defined filter at a specific position.""" + return await self.send_command( + f"DEFINE FILTER NAME={name} POSITION={position} FLASH_COUNTER={flash_counter}" + ) + + async def get_defined_filter_name(self, name, position): + """Gets the name/description of a defined filter at a specific position.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} POSITION={position} DESCRIPTION") + + async def set_defined_filter_name(self, name, position, filter_name): + """Sets the name/description of a defined filter at a specific position.""" + return await self.send_command( + f"DEFINE FILTER NAME={name} POSITION={position} DESCRIPTION={filter_name}" + ) + + async def get_defined_filter_slide_description(self, name): + """Gets the slide description of a defined filter.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} SLIDE_DESCRIPTION") + + async def set_defined_filter_slide_description(self, name, description): + """Sets the slide description of a defined filter.""" + return await self.send_command(f"DEFINE FILTER NAME={name} SLIDE_DESCRIPTION={description}") + + async def get_defined_filter_slide_usage(self, name): + """Gets the slide usage of a defined filter.""" + return await self.send_command(f"?DEFINE FILTER NAME={name} SLIDE_USAGE") + + async def set_defined_filter_slide_usage(self, name, usage): + """Sets the slide usage of a defined filter.""" + return await self.send_command(f"DEFINE FILTER NAME={name} SLIDE_USAGE={usage}") + + async def get_allowed_signal_gain_range(self): + """Gets the allowed signal gain range.""" + return await self.send_command("#GAIN SIGNAL") + + async def set_signal_gain(self, gain, label=None, wavelength=None, channel=None): + """Sets the signal gain.""" + command = "GAIN" + if label is not None: + command += f" LABEL={label}" + command += f" SIGNAL={gain}" + if wavelength is not None: + command += f" WAVELENGTH={wavelength}" + if channel is not None: + command += f" CHANNEL={channel}" + return await self.send_command(command) + + async def get_current_signal_gain(self, label=None): + """Gets the current signal gain.""" + command = "?GAIN" + if label is not None: + command += f" LABEL={label}" + command += " SIGNAL" + return await self.send_command(command) + + async def get_allowed_reference_gain_range(self): + """Gets the allowed reference gain range.""" + return await self.send_command("#GAIN REFERENCE") + + async def set_reference_gain( + self, gain, label=None, wavelength=None, carrier: Optional[FluorescenceCarrier] = None + ): + """Sets the reference gain.""" + command = "GAIN" + if label is not None: + command += f" LABEL={label}" + command += f" REFERENCE={gain}" + if wavelength is not None: + command += f" WAVELENGTH={wavelength}" + if carrier: + command += f" CARRIER={carrier.value}" + return await self.send_command(command) + + async def get_current_reference_gain(self, label=None): + """Gets the current reference gain.""" + command = "?GAIN" + if label is not None: + command += f" LABEL={label}" + command += " REFERENCE" + return await self.send_command(command) + + async def set_laser_power( + self, + state: LaserPowerState, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Sets the laser power state.""" + command = f"LASER POWER={state.value}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def set_lighting_state(self, light_type, state: LightingState): + """Sets the state of the specified lighting type.""" + return await self.send_command(f"LIGHTING TYPE={light_type.upper()} STATE={state.value}") + + async def set_lighting_intensity(self, light_type, intensity): + """Sets the intensity of the specified lighting type.""" + return await self.send_command(f"LIGHTING TYPE={light_type.upper()} INTENSITY={intensity}") + + async def get_lighting_current_state(self, light_type): + """Gets the current state of the specified lighting type.""" + return await self.send_command(f"?LIGHTING TYPE={light_type.upper()} STATE") + + async def get_lighting_current_intensity(self, light_type): + """Gets the current intensity of the specified lighting type.""" + return await self.send_command(f"?LIGHTING TYPE={light_type.upper()} INTENSITY") + + async def get_lighting_states(self): + """Gets the available lighting states.""" + response = await self.send_command("#LIGHTING STATE") + return response + + async def get_lighting_types(self): + """Gets the available lighting types.""" + response = await self.send_command("#LIGHTING TYPE") + return response + + async def get_lighting_intensity_range(self): + """Gets the allowed lighting intensity range.""" + return await self.send_command("#LIGHTING INTENSITY") + + async def get_current_lighting_details_fim(self): + """Gets the current lighting details for FIM.""" + return await self.send_command("#LIGHTING") + + async def select_lighting_fim(self, name, light_type): + """Selects the lighting for FIM.""" + return await self.send_command(f"LIGHTING TYPE={light_type.upper()} NAME={name.upper()}") + + async def set_intensity_fim( + self, name, light_type, intensity, hw_module: Optional[ModuleType] = None, module_number=None + ): + """Sets the intensity for FIM lighting.""" + command = f"LIGHTING TYPE={light_type.upper()} NAME={name.upper()} INTENSITY={intensity}" + if hw_module: + command += f" MODULE={hw_module.value}" + if module_number is not None: + command += f" NUMBER={module_number}" + return await self.send_command(command) + + async def set_state_fim(self, name, light_type, is_on: LightingState): + """Sets the state for FIM lighting.""" + return await self.send_command( + f"LIGHTING TYPE={light_type.upper()} NAME={name.upper()} STATE={is_on.value}" + ) + + async def get_current_lighting_values_fim(self): + """Gets the current lighting values for FIM.""" + return await self.send_command("?LIGHTING") + + async def try_get_current_lighting_values_fim(self): + """Tries to get the current lighting values for FIM.""" + return await self.send_command("?LIGHTING") + + async def get_lighting_values_fim(self, name, light_type): + """Gets the lighting values for a specific FIM lighting.""" + return await self.send_command(f"?LIGHTING TYPE={light_type.upper()} NAME={name.upper()}") + + async def get_lighting_details_fim(self, name, light_type): + """Gets the lighting details for a specific FIM lighting intensity.""" + return await self.send_command( + f"#LIGHTING TYPE={light_type.upper()} NAME={name.upper()} INTENSITY" + ) + + async def get_lighting_types_fim(self): + """Gets the available lighting types for FIM.""" + response = await self.send_command("#LIGHTING TYPE") + return response + + async def get_lighting_names_fim(self): + """Gets the available lighting names for FIM.""" + response = await self.send_command("#LIGHTING NAME") + return response + + async def get_lighting_intensity_ranges_fim(self): + """Gets the lighting intensity ranges for FIM.""" + return await self.send_command("#LIGHTING INTENSITY") + + async def set_mirror( + self, + mirror_type: MirrorType, + mirror_name=None, + carrier: Optional[MirrorCarrier] = None, + label=None, + ): + """Sets the mirror type and name.""" + command = f"MIRROR TYPE={mirror_type.value}" + if mirror_name: + command += f" NAME={mirror_name}" + if carrier: + command += f" CARRIER={carrier.value}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_current_mirror(self, label=None): + """Gets the current mirror settings.""" + command = "?MIRROR" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_mirror(self, carrier: MirrorCarrier, label=None): + """Gets the mirror settings for a specific carrier.""" + command = f"?MIRROR CARRIER={carrier.value}" + if label is not None: + command += f" LABEL={label}" + return await self.send_command(command) + + async def get_mirror_types(self, carrier: Optional[MirrorCarrier] = None): + """Gets the available mirror types.""" + command = "#MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += " TYPE" + response = await self.send_command(command) + return response + + async def get_mirror_names(self, carrier: Optional[MirrorCarrier] = None): + """Gets the available mirror names.""" + command = "#MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += " NAME" + response = await self.send_command(command) + return response + + async def _get_mirror_wavelengths(self, option=None, carrier: Optional[MirrorCarrier] = None): + command = "#MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + if option: + command += f" {option}" + response = await self.send_command(command) + return response + + async def get_mirror_start_ex_wavelengths(self, carrier: Optional[MirrorCarrier] = None): + """Gets the mirror start excitation wavelengths.""" + return await self._get_mirror_wavelengths("EXCITATION_START", carrier) + + async def get_mirror_end_ex_wavelengths(self, carrier: Optional[MirrorCarrier] = None): + """Gets the mirror end excitation wavelengths.""" + return await self._get_mirror_wavelengths("EXCITATION_END", carrier) + + async def get_mirror_start_em_wavelengths(self, carrier: Optional[MirrorCarrier] = None): + """Gets the mirror start emission wavelengths.""" + return await self._get_mirror_wavelengths("EMISSION_START", carrier) + + async def get_mirror_end_em_wavelengths(self, carrier: Optional[MirrorCarrier] = None): + """Gets the mirror end emission wavelengths.""" + return await self._get_mirror_wavelengths("EMISSION_END", carrier) + + async def get_mirror_measurement_modes(self, carrier: Optional[MirrorCarrier] = None): + """Gets the mirror measurement modes.""" + command = "#MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += " MEAS_MODE" + return await self.send_command(command) + + async def get_mirror_carrier(self): + """Gets the mirror carrier.""" + response = await self.send_command("#MIRROR CARRIER") + return response + + def _create_mirror_settings_target(self): + return f" MODULE={ModuleType.FLUORESCENCE.value}" + + async def define_mirror_name(self, name, position, carrier: Optional[MirrorCarrier] = None): + """Defines the name for a mirror at a specific position.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += f" POSITION={position} NAME={name} {self._create_mirror_settings_target()}" + return await self.send_command(command) + + async def define_mirror_type( + self, mirror_type: MirrorType, position, carrier: Optional[MirrorCarrier] = None + ): + """Defines the type for a mirror at a specific position.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += ( + f" POSITION={position} TYPE={mirror_type.value}{self._create_mirror_settings_target()}" + ) + return await self.send_command(command) + + async def define_mirror_excitation_start( + self, ex_start, position, carrier: Optional[MirrorCarrier] = None + ): + """Defines the excitation start wavelength for a mirror.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += ( + f" POSITION={position} EXCITATION_START={ex_start}{self._create_mirror_settings_target()}" + ) + return await self.send_command(command) + + async def define_mirror_excitation_end( + self, ex_end, position, carrier: Optional[MirrorCarrier] = None + ): + """Defines the excitation end wavelength for a mirror.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += ( + f" POSITION={position} EXCITATION_END={ex_end}{self._create_mirror_settings_target()}" + ) + return await self.send_command(command) + + async def define_mirror_emission_start( + self, em_start, position, carrier: Optional[MirrorCarrier] = None + ): + """Defines the emission start wavelength for a mirror.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += ( + f" POSITION={position} EMISSION_START={em_start}{self._create_mirror_settings_target()}" + ) + return await self.send_command(command) + + async def define_mirror_emission_end( + self, em_end, position, carrier: Optional[MirrorCarrier] = None + ): + """Defines the emission end wavelength for a mirror.""" + command = "DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += f" POSITION={position} EMISSION_END={em_end}{self._create_mirror_settings_target()}" + return await self.send_command(command) + + async def get_definable_mirror_positions(self, carrier: Optional[MirrorCarrier] = None): + """Gets the definable mirror positions.""" + command = "#DEFINE MIRROR" + if carrier: + command += f" CARRIER={carrier.value}" + command += f" POSITION{self._create_mirror_settings_target()}" + response = await self.send_command(command) + return response + + async def set_objective(self, objective_type): + """Sets the objective type.""" + return await self.send_command(f"OBJECTIVE TYPE={objective_type.upper()}") + + async def get_current_objective(self): + """Gets the current objective type.""" + return await self.send_command("?OBJECTIVE TYPE") + + async def get_objective_types(self): + """Gets the list of available objective types.""" + response = await self.send_command("#OBJECTIVE TYPE") + return response + + async def get_objective_details( + self, objective_type, hw_module: Optional[ModuleType] = None, module_number=None + ): + """Gets the details for a specific objective type.""" + command = f"?OBJECTIVE TYPE={objective_type.upper()}" + if hw_module: + command += f" MODULE={hw_module.value}" + if module_number is not None: + command += f" NUMBER={module_number}" + return await self.send_command(command) + + async def get_mtp_allowed_area(self, objective_type, mtp_area_type): + """Gets the allowed MTP area for the given objective and area type.""" + return await self.send_command( + f"#OBJECTIVE TYPE={objective_type.upper()} RANGE={mtp_area_type.upper()}" + ) diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py new file mode 100644 index 0000000000..6c86333601 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py @@ -0,0 +1,109 @@ +from enum import Enum + +from .base_control import baseControl +from .spark_enums import MovementSpeed, PlatePosition + + +class PlateColor(Enum): + BLACK = "BLACK" + WHITE = "WHITE" + TRANSPARENT = "TRANSPARENT" + NO = "NO" + + +class plateControl(baseControl): + """ + This class provides methods for controlling the plate transport system. + It includes functionalities to move the plate to absolute positions or named positions, + retrieve current positions and coordinates, and manage movement speed. + """ + + async def move_to_position(self, position: PlatePosition, additional_option=None): + """Moves the plate transport to a predefined position.""" + command = f"ABSOLUTE MODULE=MTP POSITION={position.value}" + if additional_option: + command += f" {additional_option}" + return await self.send_command(command) + + async def move_to_coordinate(self, x=None, y=None, z=None): + """Moves the plate transport to the specified coordinates.""" + command = "ABSOLUTE MODULE=MTP" + if x is not None: + command += f" X={x}" + if y is not None: + command += f" Y={y}" + if z is not None: + command += f" Z={z}" + return await self.send_command(command) + + async def get_current_position(self): + """Gets the current predefined position of the plate transport.""" + return await self.send_command("?ABSOLUTE MODULE=MTP POSITION") + + async def get_current_coordinates(self): + """Gets the current X, Y, Z coordinates of the plate transport.""" + return await self.send_command("?ABSOLUTE MODULE=MTP") + + async def get_current_coordinate(self, motor): + """Gets the current coordinate of a specific motor (X, Y, or Z).""" + return await self.send_command(f"?ABSOLUTE MODULE=MTP {motor.upper()}") + + async def get_available_positions(self): + """Gets the list of available predefined positions.""" + return await self.send_command("#ABSOLUTE MODULE=MTP POSITION") + + async def get_motor_range(self, motor): + """Gets the movement range for a specific motor (X, Y, or Z).""" + return await self.send_command(f"#ABSOLUTE MODULE=MTP {motor.upper()}") + + async def get_motor_x_range(self): + return await self.get_motor_range("X") + + async def get_motor_y_range(self): + return await self.get_motor_range("Y") + + async def get_motor_z_range(self): + return await self.get_motor_range("Z") + + async def get_available_speed_modes(self): + """Gets the available movement speed modes.""" + response = await self.send_command("#SPEED MOVEMENT MODULE=MTP") + return response + + async def get_current_motor_speed(self): + """Gets the current movement speed mode.""" + return await self.send_command("?SPEED MOVEMENT MODULE=MTP") + + async def set_motor_speed(self, speed_mode: MovementSpeed): + """Sets the movement speed mode.""" + return await self.send_command(f"SPEED MOVEMENT={speed_mode.value} MODULE=MTP") + + async def get_stacker_sensor_column(self, column_type): + """Gets the stacker sensor column state.""" + return await self.send_command(f"?STACKER SENSOR {column_type.upper()} COLUMN") + + async def get_stacker_sensor_plate(self, column_type): + """Gets the stacker sensor plate state.""" + return await self.send_command(f"?STACKER SENSOR {column_type.upper()} PLATE") + + async def get_stacker_sensor_lift(self, column_type): + """Gets the stacker sensor lift state.""" + return await self.send_command(f"?STACKER SENSOR {column_type.upper()} LIFT") + + async def stacker_stack_plate(self, column_type, plate_height, skirt_height): + """Commands the stacker to stack a plate.""" + return await self.send_command( + f"STACKER STACK COLUMN={column_type.upper()} PLATEHEIGHT={plate_height} SKIRTHEIGHT={skirt_height}" + ) + + async def stacker_get_plate( + self, column_type, plate_height, skirt_height, color: PlateColor = PlateColor.NO + ): + """Commands the stacker to get a plate.""" + return await self.send_command( + f"STACKER GET COLUMN={column_type.upper()} PLATEHEIGHT={plate_height} SKIRTHEIGHT={skirt_height} COLOUR={color.value}" + ) + + async def stacker_finish(self): + """Commands the stacker to finish its current operation.""" + return await self.send_command("STACKER FINISH") diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py new file mode 100644 index 0000000000..c9d2e0e1df --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py @@ -0,0 +1,340 @@ +import logging +from enum import Enum +from typing import Optional + +from .base_control import baseControl +from .spark_enums import InstrumentMessageType, ModuleType + + +class temperatureDevice(Enum): + PLATE = "PLATE" + + +class temperatureState(Enum): + ON = "ON" + OFF = "OFF" + + +class chillerState(Enum): + ON = "ON" + OFF = "OFF" + + +class SensorControl(baseControl): + async def read_barcode(self, force_reading=False): + """Reads the barcode.""" + command = "BARCODE READ" + if force_reading: + command += " FORCE=TRUE" + # This command uses data channel, which is not fully implemented yet. + logging.warning("Barcode reading uses data channel, not fully implemented.") + return await self.send_command(command) + + async def get_barcode_position(self): + """Gets the barcode reader position.""" + return await self.send_command("?BARCODE POSITION") + + async def get_motors( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the list of motors that can be checked for steploss.""" + command = "#CHECK STEPLOSS MOTOR" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + response = await self.send_command(command) + return response + + async def check_step_loss( + self, motor=None, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Checks for step loss on the specified motor.""" + command = "CHECK STEPLOSS" + if motor: + command += f" MOTOR={motor}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_step_loss_result( + self, motor=None, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the result of the last step loss check.""" + command = "?CHECK STEPLOSS" + if motor: + command += f" MOTOR={motor}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def check_lid(self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None): + """Checks the lid status.""" + command = "CHECK LID" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + # This command may use the data channel, not fully implemented yet. + logging.warning("Lid check may use data channel, not fully implemented.") + return await self.send_command(command) + + async def get_firmware_counter( + self, counter_name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the value of a firmware counter.""" + command = "?COUNTER" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {counter_name}" + return await self.send_command(command) + + async def get_software_counter( + self, counter_name, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the value of a software counter.""" + command = "?SW_COUNTER" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {counter_name}" + return await self.send_command(command) + + async def set_software_counter( + self, + counter_name, + value, + hw_module: Optional[ModuleType] = None, + number=None, + subcomponent=None, + ): + """Sets the value of a software counter.""" + command = "SW_COUNTER" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + command += f" {counter_name}={value}" + return await self.send_command(command) + + async def get_module_counter(self, counter, module: ModuleType): + """Gets the value of a specific counter for a module.""" + return await self.send_command(f"?COUNTER {counter} MODULE={module.value}") + + async def get_buzzer_states(self): + """Gets the available buzzer states.""" + response = await self.send_command("#GASCONTROL BUZZER") + return response + + async def enable_hardware_button(self, button): + """Enables the specified hardware button.""" + return await self.send_command(f"HWBUTTON {button.upper()}=ENABLED") + + async def disable_hardware_button(self, button): + """Disables the specified hardware button.""" + return await self.send_command(f"HWBUTTON {button.upper()}=DISABLED") + + async def is_hardware_button_enabled(self, button): + """Checks if the specified hardware button is enabled.""" + response = await self.send_command(f"?HWBUTTON {button.upper()}") + return response == f"{button.upper()}=ENABLED" + + async def get_instrument_state(self): + """Gets the instrument state.""" + return await self.send_command("?INSTRUMENT STATE") + + async def get_instrument_checksum(self): + """Gets the instrument checksum.""" + return await self.send_command("?INSTRUMENT CHECKSUM_ALL") + + async def is_plate_in_instrument(self): + """Checks if a plate is in the instrument.""" + response = await self.send_command("?INSTRUMENT PLATEPOS") + return response == "PLATEPOS=TAKEN" + + async def set_default_plate_out_position(self, left): + """Sets the default plate out position.""" + position = "LEFT" if left else "RIGHT" + return await self.send_command(f"INSTRUMENT PLATEOUT_POSITION={position}") + + async def get_current_default_plate_out_position(self): + """Gets the current default plate out position.""" + return await self.send_command("?INSTRUMENT PLATEOUT_POSITION") + + async def _get_sensor_range(self, option, hw_module: Optional[ModuleType] = None, number=None): + command = f"#SENSOR {option.upper()}" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + response = await self.send_command(command) + return response + + async def get_all_sensor_groups(self, hw_module: Optional[ModuleType] = None, number=None): + """Gets all available sensor groups.""" + command = "#SENSOR" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + response = await self.send_command(command) + return response + + async def get_temperature_sensors(self, hw_module: Optional[ModuleType] = None, number=None): + """Gets the available temperature sensors.""" + return await self._get_sensor_range("TEMPERATURE", hw_module, number) + + async def get_plate_states(self): + """Gets the possible plate states.""" + return await self._get_sensor_range("PLATEPOS") + + async def get_plate_transport_positions(self): + """Gets the possible plate transport positions.""" + return await self._get_sensor_range("LOADPOS") + + async def get_injector_carrier_states(self): + """Gets the possible injector carrier states.""" + return await self._get_sensor_range("INJECTOR_CARRIER") + + async def get_rotary_encoder_states(self): + """Gets the possible rotary encoder states.""" + return await self._get_sensor_range("ROTARYENCODER") + + async def get_current_plate_state(self): + """Gets the current plate state.""" + return await self.send_command("?SENSOR PLATEPOS") + + async def get_current_injector_carrier_state(self): + """Gets the current injector carrier state.""" + return await self.send_command("?SENSOR INJECTOR_CARRIER") + + async def get_current_temperature(self, device: temperatureDevice): + """Gets the current temperature for a specific device.""" + return await self.send_command(f"?TEMPERATURE DEVICE={device.value} CURRENT") + + async def get_current_plate_transport_position(self): + """Gets the current plate transport position.""" + return await self.send_command("?SENSOR LOADPOS") + + async def get_current_rotary_encoder_state(self): + """Gets the current rotary encoder state.""" + return await self.send_command("?SENSOR ROTARYENCODER") + + async def get_analog_digital_temperature_value(self, sensor): + """Gets the analog/digital temperature value for a specific sensor.""" + return await self.send_command(f"?SENSORVALUE TEMPERATURE {sensor}") + + async def get_analog_digital_sensor_value(self, sensor): + """Gets the analog/digital value for a specific sensor.""" + return await self.send_command(f"?SENSORVALUE {sensor}") + + async def get_led_state(self): + """Gets the power LED state.""" + return await self.send_command("?LED DEVICE=POWER STATE") + + async def set_led_state(self, state): + """Sets the power LED state.""" + return await self.send_command(f"LED DEVICE=POWER STATE={state.upper()}") + + async def get_led_color(self): + """Gets the power LED color.""" + return await self.send_command("?LED DEVICE=POWER COLOUR") + + async def set_led_color(self, color): + """Sets the power LED color.""" + return await self.send_command(f"LED DEVICE=POWER COLOUR={color.upper()}") + + async def get_temperature_devices(self): + """Gets the available temperature devices.""" + response = await self.send_command("#TEMPERATURE DEVICE") + return response + + async def get_temperature_parameters(self, device: temperatureDevice): + """Gets the parameters for a specific temperature device.""" + response = await self.send_command(f"#TEMPERATURE DEVICE={device.value}") + return response + + async def get_temperature_target_range(self, device: temperatureDevice): + """Gets the target temperature range for a specific device.""" + return await self.send_command(f"#TEMPERATURE DEVICE={device.value} TARGET") + + async def get_temperature_target_modes(self, device: temperatureDevice): + """Gets the available target modes for a specific temperature device.""" + response = await self.send_command(f"#TEMPERATURE DEVICE={device.value} TARGET_MODE") + return response + + async def get_temperature_states(self, device: temperatureDevice): + """Gets the available states for a specific temperature device.""" + response = await self.send_command(f"#TEMPERATURE DEVICE={device.value} STATE") + return response + + async def get_temperature_target(self, device: temperatureDevice): + """Gets the current target temperature for a specific device.""" + return await self.send_command(f"?TEMPERATURE DEVICE={device.value} TARGET") + + async def set_temperature_target(self, device: temperatureDevice, target): + """Sets the target temperature for a specific device.""" + return await self.send_command(f"TEMPERATURE DEVICE={device.value} TARGET={target}") + + async def get_temperature_target_mode(self, device: temperatureDevice): + """Gets the current target mode for a specific temperature device.""" + return await self.send_command(f"?TEMPERATURE DEVICE={device.value} TARGET_MODE") + + async def set_temperature_target_mode(self, device: temperatureDevice, target_mode): + """Sets the target mode for a specific temperature device.""" + return await self.send_command(f"TEMPERATURE DEVICE={device.value} TARGET_MODE={target_mode}") + + async def get_temperature_state(self, device: temperatureDevice): + """Gets the current state for a specific temperature device.""" + return await self.send_command(f"?TEMPERATURE DEVICE={device.value} STATE") + + async def set_temperature_state(self, device: temperatureDevice, state: temperatureState): + """Sets the state for a specific temperature device.""" + return await self.send_command(f"TEMPERATURE DEVICE={device.value} STATE={state.value}") + + async def get_current_chiller_state(self, device: temperatureDevice): + """Gets the current chiller state for a specific device.""" + return await self.send_command(f"?TEMPERATURE DEVICE={device.value} CHILLER") + + async def set_chiller_state(self, device: temperatureDevice, state: chillerState): + """Sets the chiller state for a specific device.""" + return await self.send_command(f"TEMPERATURE DEVICE={device.value} CHILLER={state.value}") + + async def get_temperature_time_interval_range(self): + """Gets the range for the temperature message time interval.""" + return await self.send_command( + f"#MESSAGE TYPE={InstrumentMessageType.TEMPERATURE.value} TIME_INTERVAL" + ) + + async def get_temperature_time_interval(self): + """Gets the current temperature message time interval.""" + return await self.send_command( + f"?MESSAGE TYPE={InstrumentMessageType.TEMPERATURE.value} TIME_INTERVAL" + ) + + async def set_temperature_time_interval(self, interval): + """Sets the temperature message time interval.""" + return await self.send_command( + f"MESSAGE TYPE={InstrumentMessageType.TEMPERATURE.value} TIME_INTERVAL={interval}" + ) diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py b/pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py new file mode 100644 index 0000000000..c54cef3f1b --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py @@ -0,0 +1,636 @@ +from enum import Enum + + +class BarcodePosition(Enum): + LEFT = "LEFT" + RIGHT = "RIGHT" + + +class FilterType(Enum): + UNDEFINED = "UNDEFINED" + SHORTPASS = "SP" + LONGPASS = "LP" + BANDPASS = "BP" + OPTICAL_DENSITY = "OD" + EMPTY = "EMPTY" + DARK = "DARK" + AUTOMATIC = "AUTOMATIC" + + +class FluorescenceCarrier(Enum): + FILTER_EXCITATION = "FILTER_EX" + FILTER_EMISSION = "FILTER_EM1" + FILTER_DUAL_EMISSION = "FILTER_EM2" + MONOCHROMATOR_EXCITATION = "MONO" + MONOCHROMATOR_EMISSION = "MONO" + + +class FluorescenceConfiguration(Enum): + UNKNOWN = "UNKNOWN" + FILTER_EX_FILTER_EM = "FILTER_EX_FILTER_EM" + FILTER_EX_MONO_EM = "FILTER_EX_MONO_EM" + MONO_EX_FILTER_EM = "MONO_EX_FILTER_EM" + MONO_EX_MONO_EM = "MONO_EX_MONO_EM" + + +class FluorescenceMeasurementDirection(Enum): + TOP = "FITOP" + BOTTOM = "FIBOTTOM" + + +class InjectionMode(Enum): + RINSE = "RINSE" + PRIME = "PRIME" + DISPENSE = "DISPENSE" + BACKFLUSH = "BACKFLUSH" + REFILL = "REFILL" + + +class InjectorModel(Enum): + STANDARD = "STANDARD" + ENHANCED = "ENHANCED" + + +class InjectorName(Enum): + A = "A" + B = "B" + C = "C" + + +class InjectorRefillMode(Enum): + STANDARD = "STANDARD" + BEFORE_EVERY_INJECTION = "BEFORE_EVERY_INJECTION" + + +class InjectorState(Enum): + ACTIVE = "ACTIVE" + INACTIVE = "INACTIVE" + + +class LightingName(Enum): + AUTOFOCUS = "AF" + BRIGHTFIELD = "BF" + BLUE = "UV_BLUE" + GREEN = "BLUE_GREEN" + RED = "LIME_RED" + FAR_RED = "RED_FARRED" + + +class LightingType(Enum): + AUTOFOCUS = "AF_LED" + BRIGHTFIELD = "BF_LED" + FLUORESCENCE = "FI_LED" + + +class LuminescenceType(Enum): + NONE = "NONE" + STANDARD = "STANDARD" + ENHANCED = "ENHANCED" + ALPHA = "ALPHA" + + +class MeasurementMode(Enum): + ABSORBANCE = "ABS" + CUVETTE = "CUV" + LUMINESCENCE = "LUM" + ALPHA = "ALPHA" + FLUORESCENCE_TOP = "FITOP" + FLUORESCENCE_BOTTOM = "FIBOTTOM" + FLUORESCENCE_POLARIZATION = "FP" + CELL = "CELL" + INJECTOR = "INJ" + WELL_TEMPERATURE = "WELL_TEMP" + BARCODE = "BARCODE" + FLUORESCENCE_IMAGING = "FIM" + + +class MirrorType(Enum): + HALF_HALF = "50_50" + DICHROIC = "DICHROIC" + BOTTOM = "BOTTOM" + UNUSED = "UNUSED" + AUTOMATIC = "AUTOMATIC" + + +class MtpAreaType(Enum): + SMALL = "SMALL" + MEDIUM = "MEDIUM" + LARGE = "LARGE" + + +class ObjectiveType(Enum): + UNDEFINED = "XX" + TWO_TIMES = "2X" + FOUR_TIMES = "4X" + TEN_TIMES = "10X" + + +class PlatePosition(Enum): + UNDEFINED = "UNDEFINED" + OUT_LEFT = "OUT_LEFT" + OUT_RIGHT = "OUT_RIGHT" + PLATE_IN = "PLATE_IN" + PICK_N_PLACE = "PICK_N_PLACE" + LID_LIFTER = "LIDLIFTER" + CHECK = "CHECK" + HEATING = "HEATING" + INCUBATION = "INCUBATION" + COOLING = "COOLING" + BARCODE_LEFT = "BARCODE_LEFT" + BARCODE_RIGHT = "BARCODE_RIGHT" + + +class ShakingMode(Enum): + LINEAR = "LINEAR" + ORBITAL = "ORBITAL" + DOUBLE = "DOUBLE" + + +class ShakingName(Enum): + SLOW = "SLOW" + MEDIUM = "MEDIUM" + FAST = "FAST" + + +class ColumnType(Enum): + INPUT = "INPUT" + OUTPUT = "OUTPUT" + + +class HumidityCassetteModel(Enum): + STANDARD = "STANDARD" + CYTO = "CYTO" + + +class InstrumentMessageType(Enum): + ALL = "ALL" + TEMPERATURE = "TEMPERATURE" + + +class MovementSpeed(Enum): + NORMAL = "NORMAL" + SMALLSTEP = "SMALLSTEP" + AUTOMATIC = "AUTOMATIC" + SMOOTH = "SMOOTH" + SLOW = "SLOW" + FLASH_INJECTION = "FLASH_INJECTION" + + +class StackerColumn(Enum): + PRESENT = "PRESENT" + NOT_PRESENT = "NOT_PRESENT" + UNKNOWN = "UNKNOWN" + + +class StackerPlate(Enum): + PRESENT = "PRESENT" + NOT_PRESENT = "NOT_PRESENT" + UNKNOWN = "UNKNOWN" + + +class ControlCommandType(Enum): + OFF = "OFF" + ON = "ON" + TARGET_TEMP = "TARGET_TEMP" + + +class GasBuzzerState(Enum): + OFF = "OFF" + ON = "ON" + + +class GasMode(Enum): + OFF = "OFF" + LOG = "LOG" + CTRL = "CTRL" + + +class GasOption(Enum): + O2 = "O2" + CO2 = "CO2" + + +class GasPowerState(Enum): + OFF = "OFF" + ON = "ON" + + +class GasSensorState(Enum): + OFF = "OFF" + INIT = "INIT" + READY = "READY" + + +class GasErrorMessage(Enum): + TIMEOUT = 1240 + SENSOR_HEATS_UP = 1241 + SENSOR_SIGNAL_CORRUPT = 1242 + GAS_CONFIG_ERROR = 1243 + + +class GasMessage(Enum): + CO2_CONCENTRATION = 250 + O2_CONCENTRATION = 251 + SENSOR_READY = 252 + + +class SymbioErrors(Enum): + TEMPERATURE_SENSOR_READING = 1104 + INJECTOR_CARRIER_INSERTED = 1213 + GAS_TIMEOUT = 1240 + GAS_SIGNAL_CORRUPT = 1242 + + +class SymbioMessages(Enum): + TEMPERATURE_CHAMBER = 100 + TEMPERATURE_AMBIENT = 101 + EXCITATION_FILTERSLIDE_INSERTED = 150 + EMISSION_FILTERSLIDE_INSERTED = 151 + DUAL_EMISSION_FILTERSLIDE_INSERTED = 152 + POWER_ON = 200 + POWER_OFF = 201 + START_STOP = 202 + FILTERSLIDES_OUT = 203 + PLATE_OUT_IN = 204 + GAS_CO2_CONCENTRATION = 250 + GAS_O2_CONCENTRATION = 251 + GAS_SENSOR_READY = 252 + INJECTOR_A_PRIME = 300 + INJECTOR_A_RINSE = 301 + INJECTOR_B_PRIME = 302 + INJECTOR_B_RINSE = 303 + INJECTOR_C_PRIME = 304 + INJECTOR_C_RINSE = 305 + INJECTOR_ACTION_FINISHED = 306 + STACKER_INPUT_COLUMN_PRESENT = 401 + STACKER_INPUT_COLUMN_NOT_PRESENT = 402 + STACKER_OUTPUT_COLUMN_PRESENT = 403 + STACKER_OUTPUT_COLUMN_NOT_PRESENT = 404 + + +class Mode(Enum): + BOOT = "B" + SERVICE = "S" + OPERATION = "O" + + +class ModuleName(Enum): + BOOT = "BOOT" + MAIN = "MAIN" + LUM = "LUM" + MEX = "MEX" + MEM = "MEM" + FIM = "FIM" + + +class ModuleType(Enum): + BOOTLOADER = "BOOT" + MAIN = "MAIN" + LUMINESCENCE = "LUM" + MONO_EXCITATION = "MEX" + MONO_EMISSION = "MEM" + FLUORESCENCE_IMAGING = "FIM" + ABSORBANCE = "ABS" + FLUORESCENCE = "FLUOR" + PLATE_TRANSPORT = "MTP" + STACKER = "STACKER" + INJECTOR = "INJ" + POWER_DISTRIBUTION_BOARD = "PODI" + COOLING = "COOLING" + BARCODE = "BARCODE" + CELL = "CELL" + GAS_CONTROL = "GCM" + LIFTER = "LIFT" + PICK_AND_PLACE = "PAP" + TWO_STEP_MOTORS = "2SM" + USB_CAMERA = "USBCAM" + USB_CAMERA2 = "USBCAM2" + + +class CameraMode(Enum): + PREPARE = "PREPARE" + TRIGGER = "TRIGGER" + VIDEO = "VIDEO" + + +class GasMessageType(Enum): + CONCENTRATION = "CONCENTRATION" + SENSOR_READY = "SENSOR_READY" + GAS_ERROR = "GAS_ERROR" + + +class HardwareButtons(Enum): + START_STOP = "START_STOP" + FILTER_OUT = "FILTER_OUT" + PLATE = "PLATE" + INJECTOR = "INJECTOR" + POWER = "POWER" + ALL = "ALL" + + +class InjectorCarrierState(Enum): + OPERATING_POSITION = "OPERATINGPOSITION" + PRIME_POSITION = "PRIMEPOSITION" + + +class LedColor(Enum): + RED = "RED" + GREEN = "GREEN" + BLUE = "BLUE" + YELLOW = "YELLOW" + MAGENTA = "MAGENTA" + CYAN = "CYAN" + WHITE = "WHITE" + BLACK = "BLACK" + + +class LedState(Enum): + OFF = "OFF" + STANDBY = "STANDBY" + IDLE = "IDLE" + IDLE_ACQUIRED = "IDLE_ACQUIRED" + RUN = "RUN" + ERROR = "ERROR" + USER_INTERACTION = "USER_INTERACTION" + PAUSE = "PAUSE" + SHUT_DOWN = "SHUT_DOWN" + ACTION_IMPOSSIBLE = "ACTION_IMPOSSIBLE" + + +class MotorDirection(Enum): + LEFT = "LEFT" + RIGHT = "RIGHT" + + +class MoveableCarrier(Enum): + EXCITATION_FILTER = "EXCITATION_FILTER" + EMISSION_FILTER = "EMISSION_FILTER" + DUAL_PMT_EMISSION_FILTER = "DUAL_PMT_EMISSION_FILTER" + MIRROR = "MIRROR" + DUAL_PMT_MIRROR = "DUAL_PMT_MIRROR" + ALL = "ALL" + + +class MoveableCarrierPosition(Enum): + IN = "IN" + OUT = "OUT" + + +class MtpMotor(Enum): + X = "X" + Y = "Y" + Z = "Z" + + +class PolarisationEmissionMode(Enum): + AUTOMATIC = "AUTOMATIC" + MANUAL = "MANUAL" + + +class PolarisationMode(Enum): + PARALLEL = "PARALLEL" + PERPENDICULAR = "PERPENDICULAR" + + +class Retractable(Enum): + FILTER_SLIDE = "FILTER_SLIDE" + + +class State(Enum): + ON = "ON" + OFF = "OFF" + + +class TargetMode(Enum): + AMBIENT = "AMBIENT" + FIX = "FIX" + + +class SoftwareCounter(Enum): + PLATE_0001_WELL = "PLATE_0001_WELL" + ABSORBANCE = "ABSORBANCE" + ABSORBANCE_SCAN = "ABSORBANCE_SCAN" + FLUORESCENCE_INTENSITY = "FLUORESCENCE_INTENSITY" + FLUORESCENCE_INTENSITY_SCAN = "FLUORESCENCE_INTENSITY_SCAN" + FLUORESCENCE_POLARIZATION = "FLUORESCENCE_POLARIZATION" + LUMINESCENCE = "LUMINESCENCE" + LUMINESCENCE_SCAN = "LUMINESCENCE_SCAN" + ALPHA = "ALPHA" + CELL = "CELL" + FLASH_DROPOUT = "FLASH_DROPOUT" + ON_BOARD_START = "ON_BOARD_START" + ON_BOARD_CONTINUE = "ON_BOARD_CONTINUE" + ON_BOARD_STOP = "ON_BOARD_STOP" + + +class FirmwareCounter(Enum): + LID_LIFTED = "LID_LIFTED" + X_MOVEMENT = "X_MOVEMENT" + Y_MOVEMENT = "Y_MOVEMENT" + Z_MOVEMENT = "Z_MOVEMENT" + SHAKING = "SHAKING" + HEATING_STANDARD = "HEATING_STANDARD" + HEATING_ENHANCED = "HEATING_ENHANCED" + INSTRUMENT_ON_TIME = "INSTRUMENT_ON_TIME" + FLASHES = "FLASHES" + LASER_ON_TIME = "LASER_ON_TIME" + VALVE_SWITCHES_O2 = "VALVE_SWITCHES_O2" + VALVE_SWITCHES_CO2 = "VALVE_SWITCHES_CO2" + PUMPED_VOLUME_INJECTOR_A = "PUMPED_VOLUME_INJECTOR_A" + PUMPED_VOLUME_INJECTOR_B = "PUMPED_VOLUME_INJECTOR_B" + PUMPED_VOLUME_INJECTOR_C = "PUMPED_VOLUME_INJECTOR_C" + ON_TIME = "ON_TIME" + VALVE_SWITCHES = "VALVE_SWITCHES" + READING = "READING" + + +class AreaOfInterestProperty(Enum): + X = "X" + Y = "Y" + WIDTH = "WIDTH" + HEIGHT = "HEIGHT" + + +class CameraExecutionDetails(Enum): + SUCCESSFUL = "SUCCESSFUL" + FRAME_DROP = "FRAME_DROP" + + +class InstrumentMode(Enum): + OPERATION = "OPERATION" + SERVICE = "SERVICE" + + +class Unit(Enum): + NONE = "NONE" + S = "S" + NS = "NS" + US_MICRO = "µS" + US = "US" + UM = "UM" + M = "M" + PERCENT = "PERCENT" + STEP = "STEP" + MS = "MS" + FPS = "FPS" + MHZ = "MHZ" + HZ10 = "HZ10" + ANG = "ANG" + HZ = "HZ" + C100 = "C100" + UL_MICRO = "µL" + UL = "UL" + UL_PER_S = "UL_PER_S" + DHZ = "DHZ" + PPM = "PPM" + H = "H" + ML = "ML" + KHZ = "KHZ" + + +# Limits Enums - Using FirmwareNames as values where available +class AbsorbanceLimit(Enum): + REFERENCE_DATA_RANGE_LOW = "REF_DATARANGE_LOW" + SIGNAL_DATA_RANGE_LOW = "SIG_DATARANGE_LOW" + REFERENCE_DATA_RANGE_HIGH = "REF_DATARANGE_HIGH" + SIGNAL_DATA_RANGE_HIGH = "SIG_DATARANGE_HIGH" + OD_MAX = "OD_MAX" + FLASH_DROPOUT = "FLASH_DROPOUT" + + +class CameraLimit(Enum): + ROI_START_X = "ROI_START_X" + ROI_START_Y = "ROI_START_Y" + LASER_OFFSET = "LASER_OFFSET" + INT_TIME_THIN = "INT_TIME_THIN" + INT_TIME_THICK = "INT_TIME_THICK" + + +class FluorescenceLimit(Enum): + REFERENCE_FILTER_RANGE_LOW = "REF_FIL_RANGE_LOW" + REFERENCE_FILTER_RANGE_HIGH = "REF_FIL_RANGE_HIGH" + REFERENCE_MONO_RANGE_LOW = "REF_MONO_RANGE_LOW" + REFERENCE_MONO_RANGE_HIGH = "REF_MONO_RANGE_HIGH" + SIGNAL_RANGE_LOW = "SIGNAL_RANGE_LOW" + SIGNAL_RANGE_HIGH = "SIGNAL_RANGE_HIGH" + FLASH_DROPOUT = "FLASH_DROPOUT" + EX_WL_FILTER_TOP_MIN = "EX_WL_FIL_TOP_MIN" + EX_WL_FILTER_TOP_MAX = "EX_WL_FIL_TOP_MAX" + EM_WL_FILTER_TOP_MIN = "EM_WL_FIL_TOP_MIN" + EM_WL_FILTER_TOP_MAX = "EM_WL_FIL_TOP_MAX" + EX_WL_FILTER_BOTTOM_MIN = "EX_WL_FIL_BOTTOM_MIN" + EX_WL_FILTER_BOTTOM_MAX = "EX_WL_FIL_BOTTOM_MAX" + EM_WL_FILTER_BOTTOM_MIN = "EM_WL_FIL_BOTTOM_MIN" + EM_WL_FILTER_BOTTOM_MAX = "EM_WL_FIL_BOTTOM_MAX" + EX_WL_FP_MIN = "EX_WL_FP_MIN" + EX_WL_FP_MAX = "EX_WL_FP_MAX" + EM_WL_FP_MIN = "EM_WL_FP_MIN" + EM_WL_FP_MAX = "EM_WL_FP_MAX" + EX_WL_MONO_MIN = "EX_WL_MONO_MIN" + EX_WL_MONO_MAX = "EX_WL_MONO_MAX" + EM_WL_MONO_MIN = "EM_WL_MONO_MIN" + EM_WL_MONO_MAX = "EM_WL_MONO_MAX" + MAX_CHAR_FILTER_DESCRIPTION = "MAX_CHAR_FILTERDESCR" + + +class LuminescenceLimit(Enum): + DARK_TIME_MIN = "DARKTIMEMIN" + LID_CHECK_MAX = "LIDCHECKMAX" + DARK_MAX = "DARKMAX" + DARK_TIME_MAX = "DARKTIMEMAX" + ALPHA_TEMP_SLOPE = "TEMP_SLOPE" + ALPHA_TEMP_INTERCEPT = "TEMP_INTERCEPT" + + +class PlateTransportLimit(Enum): + MIN_PLATE_HEIGHT = "MIN_PLATEHEIGHT" + MAX_PLATE_HEIGHT = "MAX_PLATEHEIGHT" + PLATE_REFERENCE_WIDTH = "PLATE_REF_WIDTH" + MAX_PLATE_FORMAT = "MAX_PLATEFORMAT" + DELTA_WELL_TEMP_LUM_X = "DELTAX_WELLTEMP_LUM" + DELTA_WELL_TEMP_LUM_Y = "DELTAY_WELLTEMP_LUM" + + +class ConfigAxis(Enum): + X = "X" + Y = "Y" + Z = "Z" + + +class TriggerMode(Enum): + SOFTWARE = "SOFTWARE" + EXTERNAL = "EXTERNAL" + + +class FlippingMode(Enum): + STANDARD = "STANDARD" + HORIZONTAL = "HORIZONTAL" + VERTICAL = "VERTICAL" + BOTH = "BOTH" + + +class BrightnessState(Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class TemperatureDevice(Enum): + PLATE = "PLATE" + + +class TemperatureState(Enum): + ON = "ON" + OFF = "OFF" + + +class ChillerState(Enum): + ON = "ON" + OFF = "OFF" + + +class MirrorCarrier(Enum): + MIRROR1 = "MIRROR1" + + +class LaserPowerState(Enum): + ON = "ON" + OFF = "OFF" + + +class LightingState(Enum): + ON = "ON" + OFF = "OFF" + + +class LidLiftState(Enum): + ON = "ON" + OFF = "OFF" + + +class RetractionState(Enum): + ENABLED = "ENABLED" + DISABLED = "DISABLED" + + +class PlateColor(Enum): + BLACK = "BLACK" + WHITE = "WHITE" + TRANSPARENT = "TRANSPARENT" + NO = "NO" + + +class ScanDirection(Enum): + UP = "UP" + DOWN = "DOWN" + ALTERNATE_UP = "ALTERNATE_UP" + ALTERNATE_DOWN = "ALTERNATE_DOWN" + + +class ScanDarkState(Enum): + TRUE = "TRUE" + FALSE = "FALSE" + + +class SimulationState(Enum): + ON = "ON" + OFF = "OFF" diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py b/pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py new file mode 100644 index 0000000000..9a6ee70c9c --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py @@ -0,0 +1,171 @@ +from typing import Optional + +from .base_control import baseControl +from .spark_enums import ModuleType, SimulationState + + +class SystemControl(baseControl): + async def get_status(self): + return await self.send_command("?INSTRUMENT STATE") + + async def terminate(self): + """Terminates the connection to the device.""" + return await self.send_command("TERMINATE") + + async def get_version( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Gets the version details.""" + command = "?VERSION" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def get_alias(self): + """Gets the instrument alias.""" + return await self.send_command("?ALIAS NAME") + + async def set_alias(self, alias): + """Sets the instrument alias.""" + return await self.send_command(f"ALIAS NAME={alias}") + + async def reset(self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None): + """Resets the specified module or the entire instrument.""" + command = "RESET" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def reset_with_reset_all(self, hw_module: ModuleType, number=None, subcomponent=None): + """Resets the specified module with the ALL option.""" + command = "RESET ALL" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def reset_to_bootloader( + self, hw_module: Optional[ModuleType] = None, number=None, subcomponent=None + ): + """Resets the specified module to the bootloader.""" + command = "RESET TO_BOOTLOADER" + if hw_module: + command += f" MODULE={hw_module.value}" + if number is not None: + command += f" NUMBER={number}" + if subcomponent: + command += f" SUB={subcomponent}" + return await self.send_command(command) + + async def reset_all(self): + """Resets all modules.""" + return await self.send_command("RESET ALL") + + async def get_available_debug_levels(self): + """Gets the available debug levels.""" + return await self.send_command("#DEBUG LEVEL") + + async def set_debug_level(self, level): + """Sets the debug level.""" + return await self.send_command(f"DEBUG LEVEL={level}") + + async def get_current_debug_level(self): + """Gets the current debug level.""" + return await self.send_command("?DEBUG LEVEL") + + async def is_simulation_active(self): + """Checks if simulation mode is active.""" + response = await self.send_command("?SIMULATE STATE") + return response == f"STATE={SimulationState.ON.value}" + + async def set_simulation_state(self, state: SimulationState): + """Sets the simulation state.""" + return await self.send_command(f"SIMULATE STATE={state.value}") + + async def turn_simulate_on(self): + """Turns simulation mode on.""" + return await self.set_simulation_state(SimulationState.ON) + + async def turn_simulate_off(self): + """Turns simulation mode off.""" + return await self.set_simulation_state(SimulationState.OFF) + + async def define_simulation_data_generation_pattern(self, measurement_mode, data_pattern): + """Defines the data generation pattern for simulation mode.""" + return await self.send_command( + f"SIMULATION MEASMODE={measurement_mode} GENERATE_DATA={data_pattern}" + ) + + async def _get_time_range(self, option): + return await self.send_command(f"#TIME {option.upper()}") + + async def _set_time(self, option, value, label=None): + label_str = f" LABEL={label}" if label is not None else "" + return await self.send_command(f"TIME{label_str} {option.upper()}={value}") + + async def _get_time(self, option, label=None): + label_str = f" LABEL={label}" if label is not None else "" + return await self.send_command(f"?TIME{label_str} {option.upper()}") + + async def get_settle_time_range(self): + """Gets the range for settle time.""" + return await self._get_time_range("SETTLE") + + async def set_settle_time(self, value): + """Sets the settle time.""" + return await self._set_time("SETTLE", value) + + async def get_current_settle_time(self): + """Gets the current settle time.""" + return await self._get_time("SETTLE") + + async def get_lag_time_range(self): + """Gets the range for lag time.""" + return await self._get_time_range("LAG") + + async def set_lag_time(self, value, label=None): + """Sets the lag time.""" + return await self._set_time("LAG", value, label) + + async def get_current_lag_time(self, label=None): + """Gets the current lag time.""" + return await self._get_time("LAG", label) + + async def get_integration_time_range(self): + """Gets the range for integration time.""" + return await self._get_time_range("INTEGRATION") + + async def set_integration_time(self, value, label=None): + """Sets the integration time.""" + return await self._set_time("INTEGRATION", value, label) + + async def get_current_integration_time(self, label=None): + """Gets the current integration time.""" + return await self._get_time("INTEGRATION", label) + + async def get_excitation_time_range(self): + """Gets the range for excitation time.""" + return await self._get_time_range("EXCITATION") + + async def set_excitation_time(self, value, label=None): + """Sets the excitation time.""" + return await self._set_time("EXCITATION", value, label) + + async def get_current_excitation_time(self, label=None): + """Gets the current excitation time.""" + return await self._get_time("EXCITATION", label) + + async def get_dead_time(self): + """Gets the dead time.""" + return await self.send_command("?TIME DEADTIME") diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py b/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py new file mode 100644 index 0000000000..c05a57cae8 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py @@ -0,0 +1,287 @@ +import asyncio +import logging +import time +from typing import Dict, List, Optional + +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.plate_reading.utils import _get_min_max_row_col_tuples +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .controls.config_control import ConfigControl +from .controls.data_control import DataControl +from .controls.measurement_control import MeasurementMode, ScanDirection, measurement_control +from .controls.optics_control import FilterType, FluorescenceCarrier, MirrorType, OpticsControl +from .controls.plate_transport_control import MovementSpeed, PlatePosition, plateControl +from .controls.sensor_control import InstrumentMessageType, SensorControl +from .controls.system_control import SystemControl +from .spark_processor import AbsorbanceProcessor, FluorescenceProcessor +from .spark_reader_async import SparkDevice, SparkEndpoint, SparkReaderAsync + +logger = logging.getLogger(__name__) + + +class AutoReaderProxy: + def __init__(self, target, reader, device): + self._target = target + self._reader = reader + self._device = device + + def __getattr__(self, name): + attr = getattr(self._target, name) + + if asyncio.iscoroutinefunction(attr): + + async def wrapper(*args, **kwargs): + async with self._reader.reading(self._device): + return await attr(*args, **kwargs) + + return wrapper + return attr + + +class SparkBackend(PlateReaderBackend): + """Backend for Tecan Spark plate reader.""" + + def __init__(self, vid: int = 0x0C47) -> None: + self.vid = vid + self.reader = SparkReaderAsync(vid=self.vid) + + self.absorbance_processor = AbsorbanceProcessor() + self.fluorescence_processor = FluorescenceProcessor() + + # Initialize .controls + self.config = AutoReaderProxy( + ConfigControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.plate = AutoReaderProxy( + plateControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.measure = AutoReaderProxy( + measurement_control(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.optics = AutoReaderProxy( + OpticsControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.system = AutoReaderProxy( + SystemControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.sensors = AutoReaderProxy( + SensorControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT + ) + self.data = AutoReaderProxy(DataControl(self.reader), self.reader, SparkDevice.PLATE_TRANSPORT) + + async def setup(self) -> None: + """Set up the plate reader.""" + self.reader.connect() + await self.config.init_module() + await self.data.turn_all_interval_messages_off() + + async def get_average_temperature(self) -> Optional[float]: + """Calculate average chamber temperature from recorded messages (ID 100).""" + temp_msgs = [m for m in self.reader.msgs if m.get("number") == 100] + if not temp_msgs: + return None + + total_temp = 0.0 + count = 0 + for msg in temp_msgs: + try: + total_temp += float(msg["args"][0]) + count += 1 + except (IndexError, ValueError, KeyError): + continue + + if count == 0: + return None + + return (total_temp / count) / 100.0 + + async def stop(self) -> None: + """Close connections.""" + await self.reader.close() + + async def open(self) -> None: + """Move the plate carrier out.""" + await self.plate.move_to_position(PlatePosition.OUT_RIGHT) + + async def close(self, plate: Optional[Plate] = None) -> None: + """Move the plate carrier in.""" + await self.plate.move_to_position(PlatePosition.PLATE_IN) + + async def scan_plate_range(self, plate: Plate, wells: Optional[List[Well]], z: float = 9150): + """Scan the plate range.""" + num_cols, num_rows, size_y = plate.num_items_x, plate.num_items_y, plate.get_size_y() + if num_cols < 2 or num_rows < 2: + raise ValueError("Plate must have at least 2 rows and 2 columns to calculate well spacing.") + top_left_well = plate.get_item(0) + if top_left_well.location is None: + raise ValueError("Top left well location is not set.") + top_left_well_center = top_left_well.location + top_left_well.get_anchor(x="c", y="c") + loc_A1 = plate.get_item("A1").location + loc_A2 = plate.get_item("A2").location + loc_B1 = plate.get_item("B1").location + if loc_A1 is None or loc_A2 is None or loc_B1 is None: + raise ValueError("Well locations for A1, A2, or B1 are not set.") + dx = loc_A2.x - loc_A1.x + dy = loc_A1.y - loc_B1.y + + # Determine rectangles to scan + if wells is None: + # Scan entire plate + rects = [(0, 0, num_rows - 1, num_cols - 1)] + else: + rects = _get_min_max_row_col_tuples(wells, plate) + + for min_row, min_col, max_row, max_col in rects: + for row_idx in range(min_row, max_row + 1): + y_pos = round((size_y - top_left_well_center.y + dy * row_idx) * 1000) + + start_x = round((top_left_well_center.x + dx * min_col) * 1000) + end_x = round((top_left_well_center.x + dx * max_col) * 1000) + num_points_x = max_col - min_col + 1 + await self.measure.measure_range_in_x_pointwise(start_x, end_x, y_pos, z, num_points_x) + + async def read_absorbance( + self, + plate: Plate, + wells: Optional[List[Well]], + wavelength: int, + bandwidth: int = 200, + num_reads: int = 10, + ) -> List[Dict]: + """Read absorbance.""" + + # Initialize + self.reader.clear_messages() + await self.data.set_interval(InstrumentMessageType.TEMPERATURE, 200) + # Setup Measurement + await self.measure.set_measurement_mode(MeasurementMode.ABSORBANCE) + await self.measure.start_measurement() + await self.plate.set_motor_speed(MovementSpeed.NORMAL) + await self.measure.set_scan_direction(ScanDirection.UP) + await self.system.set_settle_time(50000) + await self.measure.set_number_of_reads(num_reads, label=1) + await self.optics.set_excitation_filter( + FilterType.BANDPASS, wavelength=wavelength * 10, bandwidth=bandwidth, label=1 + ) + + # Start Background Read + bg_task, stop_event, results = await self.reader.start_background_read( + SparkDevice.ABSORPTION, SparkEndpoint.BULK_IN + ) + + try: + # Execute Measurement Sequence + await self.measure.prepare_instrument(measure_reference=True) + + await self.scan_plate_range(plate, wells) + measurement_time = time.time() + + finally: + stop_event.set() + await bg_task + + await self.measure.end_measurement() + await self.data.turn_all_interval_messages_off() + + # Process results + data_matrix = self.absorbance_processor.process(results) + avg_temp = await self.get_average_temperature() + + # Construct the response + return [ + { + "wavelength": wavelength, + "time": measurement_time, + "temperature": avg_temp if avg_temp is not None else 0.0, + "data": data_matrix, + } + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float = 20000, # Unused + bandwidth: int = 200, + num_reads: int = 30, + gain: int = 117, + ) -> List[Dict]: + """Read fluorescence.""" + + ex_wavelength = excitation_wavelength * 10 + em_wavelength = emission_wavelength * 10 + + # Initialize + self.reader.clear_messages() + await self.data.set_interval(InstrumentMessageType.TEMPERATURE, 200) + + # Setup Measurement + await self.measure.set_measurement_mode(MeasurementMode.FLUORESCENCE_TOP) + await self.measure.start_measurement() + await self.plate.set_motor_speed(MovementSpeed.NORMAL) + + # System Settings + await self.system.set_integration_time(40) + await self.system.set_lag_time(0) + await self.system.set_settle_time(0) + + # Optics Settings + await self.optics.set_beam_diameter(5400) + await self.optics.set_emission_filter( + FilterType.BANDPASS, + wavelength=em_wavelength, + bandwidth=bandwidth, + carrier=FluorescenceCarrier.MONOCHROMATOR_EMISSION, + ) + await self.optics.set_excitation_filter( + FilterType.BANDPASS, + wavelength=ex_wavelength, + bandwidth=bandwidth, + carrier=FluorescenceCarrier.MONOCHROMATOR_EXCITATION, + ) + + await self.measure.set_scan_direction(ScanDirection.ALTERNATE_UP) + await self.optics.set_mirror(mirror_type=MirrorType.AUTOMATIC) + await self.optics.set_signal_gain(gain) + await self.measure.set_number_of_reads(num_reads) + + # Start Background Read + bg_task, stop_event, results = await self.reader.start_background_read( + SparkDevice.FLUORESCENCE, SparkEndpoint.BULK_IN1 + ) + + try: + # Execute Measurement Sequence + await self.measure.prepare_instrument(measure_reference=True) + await self.scan_plate_range(plate, wells, focal_height) + measurement_time = time.time() + + finally: + stop_event.set() + await bg_task + + await self.measure.end_measurement() + await self.data.turn_all_interval_messages_off() + + # Process results + data_matrix = self.fluorescence_processor.process(results) + avg_temp = await self.get_average_temperature() + + return [ + { + "ex_wavelength": excitation_wavelength, + "em_wavelength": emission_wavelength, + "time": measurement_time, + "temperature": avg_temp if avg_temp is not None else 0.0, + "data": data_matrix, + } + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[Dict]: + raise NotImplementedError("Luminescence will be implemented in the future.") diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py b/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py new file mode 100644 index 0000000000..a7b2cec5a0 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py @@ -0,0 +1,144 @@ +import asyncio +import sys +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.plate_reading.tecan.spark20m.spark_backend import SparkBackend, SparkDevice +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +sys.modules["usb.core"] = MagicMock() +sys.modules["usb.util"] = MagicMock() + + +class TestSparkBackend(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + # Patch SparkReaderAsync + self.reader_patcher = patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_backend.SparkReaderAsync" + ) + self.MockReaderClass = self.reader_patcher.start() + self.mock_reader = self.MockReaderClass.return_value + + # Mock reading context manager + self.mock_reading_cm = MagicMock() + self.mock_reading_cm.__aenter__ = AsyncMock() + self.mock_reading_cm.__aexit__ = AsyncMock() + self.mock_reader.reading.return_value = self.mock_reading_cm + + # Patch Processors + self.abs_proc_patcher = patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_backend.AbsorbanceProcessor" + ) + self.MockAbsProcClass = self.abs_proc_patcher.start() + self.mock_abs_proc = self.MockAbsProcClass.return_value + + self.fluo_proc_patcher = patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_backend.FluorescenceProcessor" + ) + self.MockFluoProcClass = self.fluo_proc_patcher.start() + self.mock_fluo_proc = self.MockFluoProcClass.return_value + + self.backend = SparkBackend() + + async def asyncTearDown(self): + self.reader_patcher.stop() + self.abs_proc_patcher.stop() + self.fluo_proc_patcher.stop() + + async def test_setup(self): + await self.backend.setup() + self.mock_reader.connect.assert_called_once() + # Verify that init_module was called and it used the reading context + self.mock_reader.reading.assert_called_with(SparkDevice.PLATE_TRANSPORT) + + async def test_open(self): + await self.backend.open() + self.mock_reader.reading.assert_called_with(SparkDevice.PLATE_TRANSPORT) + + async def test_read_absorbance(self): + # Mock background read + stop_event = MagicMock() + bg_task: asyncio.Future = asyncio.Future() + bg_task.set_result(None) + self.mock_reader.start_background_read = AsyncMock(return_value=(bg_task, stop_event, [])) + + self.mock_abs_proc.process.return_value = [[0.5]] + + plate = MagicMock(spec=Plate) + plate.num_items_x = 2 + plate.num_items_y = 2 + plate.get_size_y.return_value = 100 + + well = MagicMock(spec=Well) + well.parent = plate + well.get_row.return_value = 0 + well.get_column.return_value = 0 + well.location = MagicMock() + well.location.x = 0 + well.location.y = 0 + well.location.z = 0 + well.get_anchor.return_value = MagicMock() + + plate.get_item.return_value = well + + results = await self.backend.read_absorbance(plate, None, wavelength=600) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["wavelength"], 600) + self.assertEqual(results[0]["data"], [[0.5]]) + + async def test_read_fluorescence(self): + # Mock background read + stop_event = MagicMock() + bg_task: asyncio.Future = asyncio.Future() + bg_task.set_result(None) + self.mock_reader.start_background_read = AsyncMock(return_value=(bg_task, stop_event, [])) + + self.mock_fluo_proc.process.return_value = [[100.0]] + + plate = MagicMock(spec=Plate) + plate.num_items_x = 2 + plate.num_items_y = 2 + plate.get_size_y.return_value = 100 + + well = MagicMock(spec=Well) + well.parent = plate + well.get_row.return_value = 0 + well.get_column.return_value = 0 + well.location = MagicMock() + well.location.x = 0 + well.location.y = 0 + well.location.z = 0 + well.get_anchor.return_value = MagicMock() + + plate.get_item.return_value = well + + results = await self.backend.read_fluorescence( + plate, [well], excitation_wavelength=480, emission_wavelength=520 + ) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["ex_wavelength"], 480) + self.assertEqual(results[0]["em_wavelength"], 520) + self.assertEqual(results[0]["data"], [[100.0]]) + + async def test_get_average_temperature(self): + # Mock reader messages + self.mock_reader.msgs = [ + {"number": 100, "args": ["2500"]}, # 25.00 + {"number": 100, "args": ["2600"]}, # 26.00 + {"number": 200, "args": ["something else"]}, + ] + + temp = await self.backend.get_average_temperature() + self.assertEqual(temp, 25.5) + + async def test_get_average_temperature_empty(self): + self.mock_reader.msgs = [] + temp = await self.backend.get_average_temperature() + self.assertIsNone(temp) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py b/pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py new file mode 100644 index 0000000000..ee853fbfe2 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py @@ -0,0 +1,498 @@ +import json +import logging +import struct +from collections import deque +from typing import Any, Dict, List, TypedDict + +logger = logging.getLogger(__name__) + + +class TDCLType(TypedDict): + name: str + size: int + format: str + + +TDCL_DATA_TYPE_MAP: Dict[int, TDCLType] = { + 0x00: {"name": "U16RD", "size": 2, "format": ">H"}, + 0x01: {"name": "U32RD", "size": 4, "format": ">I"}, + 0x02: {"name": "U16MD", "size": 2, "format": ">H"}, + 0x03: {"name": "U16MD2", "size": 2, "format": ">H"}, + 0x04: {"name": "U16MD3", "size": 2, "format": ">H"}, + 0x05: {"name": "U16MD4", "size": 2, "format": ">H"}, + 0x06: {"name": "U16MD5", "size": 2, "format": ">H"}, + 0x07: {"name": "U16MD6", "size": 2, "format": ">H"}, + 0x08: {"name": "U16MD7", "size": 2, "format": ">H"}, + 0x09: {"name": "U16MD8", "size": 2, "format": ">H"}, + 0x0A: {"name": "x100U16TEMP", "size": 2, "format": ">H"}, # Divide by 100 + 0x0B: {"name": "x10U16RWL", "size": 2, "format": ">H"}, # Divide by 10 + 0x0C: {"name": "U32TIME", "size": 4, "format": ">I"}, + 0x0D: {"name": "U32DARK", "size": 4, "format": ">I"}, + 0x0E: {"name": "U32MD", "size": 4, "format": ">I"}, + 0x0F: {"name": "U8RATIO", "size": 1, "format": ">B"}, + 0x10: {"name": "U16ATT", "size": 2, "format": ">H"}, + 0x11: {"name": "U16GAIN", "size": 2, "format": ">H"}, + 0x12: {"name": "U16MULT", "size": 2, "format": ">H"}, + 0x13: {"name": "U16MULT_H", "size": 2, "format": ">H"}, + 0x14: {"name": "U32MTIME", "size": 4, "format": ">I"}, + 0x15: {"name": "U16RD_DARK", "size": 2, "format": ">H"}, + 0x16: {"name": "U16MD_DARK", "size": 2, "format": ">H"}, + 0x17: {"name": "x10U16MWL", "size": 2, "format": ">H"}, # Divide by 10 + 0x18: {"name": "U16MGAIN", "size": 2, "format": ">H"}, + 0x19: {"name": "U8BYTE", "size": 1, "format": ">B"}, + 0x1A: {"name": "U16READ_COUNT", "size": 2, "format": ">H"}, + 0x1B: {"name": "U16RD_HOR", "size": 2, "format": ">H"}, + 0x1C: {"name": "U16MD_HOR", "size": 2, "format": ">H"}, + 0x1D: {"name": "U16RD_VER", "size": 2, "format": ">H"}, + 0x1E: {"name": "U16MD_VER", "size": 2, "format": ">H"}, + 0x1F: {"name": "U8MIR_POS", "size": 1, "format": ">B"}, + 0x20: {"name": "U16VIB", "size": 2, "format": ">H"}, +} + +PACKET_TYPE = { + 1: "MsgAscii", + 2: "MsgTerminate", + 3: "MsgBinary", + 129: "RespReady", + 130: "RespTerminate", + 131: "RespBinary", + 132: "RespBusy", + 133: "RespMessage", + 134: "RespError", + 135: "RespLog", + 136: "RespBinaryHeader", + 137: "RespAsyncError", +} + + +# --- Payload Reader Helpers --- +def _read_bytes(payload: bytes, offset: int, length: int) -> bytes: + if offset + length > len(payload): + raise ValueError( + f"Payload too short: need {length} bytes at offset {offset}, got {len(payload) - offset}" + ) + return payload[offset : offset + length] + + +def _read_u8(payload: bytes, offset: int) -> int: + return int(struct.unpack(">B", _read_bytes(payload, offset, 1))[0]) + + +def _read_u16(payload: bytes, offset: int) -> int: + return int(struct.unpack(">H", _read_bytes(payload, offset, 2))[0]) + + +def _read_u32(payload: bytes, offset: int) -> int: + return int(struct.unpack(">I", _read_bytes(payload, offset, 4))[0]) + + +def _read_string(payload: bytes, offset: int, length: int) -> str: + return _read_bytes(payload, offset, length).decode("utf-8", errors="ignore") + + +class SparkPacket: + def __init__(self, data_bytes: bytes): + self.raw_data = data_bytes + if len(self.raw_data) < 5: + raise ValueError("Packet too short") + + self.indicator = self.raw_data[0] + self.type = PACKET_TYPE.get(self.indicator, f"Unknown_{self.indicator}") + self.seq_num = self.raw_data[1] + self.payload_len = _read_u16(self.raw_data, 2) + payload_end = 4 + self.payload_len + if len(self.raw_data) < payload_end + 1: + raise ValueError("Packet data shorter than indicated payload length") + + self.payload_bytes = self.raw_data[4:payload_end] + self.checksum = self.raw_data[payload_end] + self.parsed_payload = self._parse_payload() + + def _parse_payload(self) -> Dict[str, Any]: + try: + if self.indicator == 129: + return self._parse_resp_ready() + if self.indicator == 130: + return self._parse_resp_terminate() + if self.indicator == 131: + return self._parse_resp_binary(is_header=False) + if self.indicator == 132: + return self._parse_resp_busy() + if self.indicator == 133: + return self._parse_resp_message() + if self.indicator == 134: + return self._parse_resp_error(is_async=False) + if self.indicator == 135: + return self._parse_resp_log() + if self.indicator == 136: + return self._parse_resp_binary(is_header=True) + if self.indicator == 137: + return self._parse_resp_error(is_async=True) + return {"raw_payload": self.payload_bytes} + except Exception as e: + logger.error(f"Error parsing payload for type {self.type} (seq {self.seq_num}): {e}") + return {"parsing_error": str(e), "raw_payload": self.payload_bytes} + + def _parse_resp_ready(self) -> Dict[str, Any]: + return { + "message": _read_string(self.payload_bytes, 0, len(self.payload_bytes)) + if self.payload_bytes + else None + } + + def _parse_resp_terminate(self) -> Dict[str, Any]: + return {"time": _read_u32(self.payload_bytes, 0) if len(self.payload_bytes) >= 4 else None} + + def _parse_resp_binary(self, is_header=False) -> Dict[str, Any]: + return {"is_header": is_header, "data": self.payload_bytes} + + def _parse_resp_busy(self) -> Dict[str, Any]: + return {"time": _read_u32(self.payload_bytes, 0) if len(self.payload_bytes) >= 4 else None} + + def _parse_resp_log(self) -> Dict[str, Any]: + return {"message": _read_string(self.payload_bytes, 0, len(self.payload_bytes))} + + def _parse_resp_message(self) -> Dict[str, Any]: + offset = 0 + number = _read_u16(self.payload_bytes, offset) + offset += 2 + message_str = _read_string(self.payload_bytes, offset, len(self.payload_bytes) - offset) + parts = message_str.split("|") + return {"number": number, "format": parts[0], "args": parts[1:]} + + def _parse_resp_error(self, is_async=False) -> Dict[str, Any]: + offset = 0 + timestamp = _read_u32(self.payload_bytes, offset) + offset += 4 + number = _read_u16(self.payload_bytes, offset) + offset += 2 + message_str = _read_string(self.payload_bytes, offset, len(self.payload_bytes) - offset) + parts = message_str.split("|") + return { + "async": is_async, + "timestamp": timestamp, + "number": number, + "format": parts[0], + "args": parts[1:], + } + + def to_dict(self) -> Dict[str, Any]: + payload_serializable = {} + if self.parsed_payload: + for k, v in self.parsed_payload.items(): + if isinstance(v, bytes): + payload_serializable[k] = v.hex() + else: + payload_serializable[k] = v + return { + "type": self.type, + "indicator": self.indicator, + "seq_num": self.seq_num, + "payload_len": self.payload_len, + "payload": payload_serializable, + "checksum": self.checksum, + } + + +class MeasurementBlock: + def __init__(self, header_packet: SparkPacket, data_packets: deque[SparkPacket]): + if not header_packet or header_packet.indicator != 136: + raise ValueError("Invalid header packet provided") + + self.header_packet = header_packet + self.data_packets = data_packets + self.seq_num = header_packet.seq_num + self.byte_buffer = b"" + + header_type_codes = list(self.header_packet.parsed_payload["data"]) + self.header_types = [] + for code in header_type_codes: + type_info = TDCL_DATA_TYPE_MAP.get(code) + if type_info: + self.header_types.append(type_info) + else: + logger.warning(f"Unknown TDCL data type code: {code} in seq {self.seq_num}") + self.header_type_names = [t["name"] for t in self.header_types] + + def _ensure_buffer(self, num_bytes: int): + while len(self.byte_buffer) < num_bytes: + if not self.data_packets: + raise ValueError( + f"Incomplete data: Needed {num_bytes}, buffer has {len(self.byte_buffer)} for seq {self.seq_num}" + ) + self.byte_buffer += self.data_packets.popleft().parsed_payload["data"] + + def _consume_buffer(self, num_bytes: int) -> bytes: + self._ensure_buffer(num_bytes) + data = self.byte_buffer[:num_bytes] + self.byte_buffer = self.byte_buffer[num_bytes:] + return data + + def _read_u16_from_buffer(self) -> int: + return _read_u16(self._consume_buffer(2), 0) + + def _parse_generic_payload(self, types: List[TDCLType]) -> Dict[str, Any]: + parsed = {} + for i, type_info in enumerate(types): + size = type_info["size"] + fmt = type_info["format"] + name = type_info["name"] + + data_bytes = self._consume_buffer(size) + try: + value = struct.unpack(fmt, data_bytes)[0] + except struct.error as e: + raise ValueError(f"Error unpacking {name} ({fmt}): {e}") + + if name == "x100U16TEMP": + value /= 100.0 + if name in ["x10U16RWL", "x10U16MWL"]: + value /= 10.0 + + field_name = f"{name}_{i}" + parsed[field_name] = value + return parsed + + def parse(self) -> Dict[str, Any]: + result = { + "sequence_number": self.seq_num, + "header_types": self.header_type_names, + } + + try: + inner_mult_index = -1 + rd_md_found = False + for i in range(len(self.header_type_names) - 1, -1, -1): + if "U16RD" in self.header_type_names[i] or "U16MD" in self.header_type_names[i]: + rd_md_found = True + if rd_md_found and self.header_type_names[i] == "U16MULT": + inner_mult_index = i + break + + outer_mult_index = -1 + if inner_mult_index > 0: + for i in range(inner_mult_index - 1, -1, -1): + if self.header_type_names[i] == "U16MULT": + outer_mult_index = i + break + + if outer_mult_index != -1: + logger.info(f"Detected Nested MULT structure for seq {self.seq_num}") + result["structure_type"] = "nested_mult" + self._parse_nested_mult(result, outer_mult_index, inner_mult_index) + elif inner_mult_index != -1: + logger.info(f"Detected Single MULT-RD-MD structure for seq {self.seq_num}") + result["structure_type"] = "single_mult" + self._parse_single_mult(result, inner_mult_index) + else: + logger.info(f"Using generic linear parser for seq {self.seq_num}") + result["structure_type"] = "linear" + self._parse_linear(result) + + if self.byte_buffer: + result["remaining_buffer"] = self.byte_buffer.hex() + logger.warning( + f"Remaining buffer after parsing seq {self.seq_num}: {len(self.byte_buffer)} bytes" + ) + + except Exception as e: + result["parsing_error"] = str(e) + logger.error(f"Error in parse_measurement_block for seq {self.seq_num}: {e}", exc_info=True) + if self.byte_buffer: + result["raw_payload_on_error"] = self.byte_buffer.hex() + + return result + + def _parse_nested_mult( + self, result: Dict[str, Any], outer_mult_index: int, inner_mult_index: int + ): + outer_mult_types = self.header_types[:outer_mult_index] + if outer_mult_types: + result.update(self._parse_generic_payload(outer_mult_types)) + + outer_mult = self._read_u16_from_buffer() + result["outer_mult"] = outer_mult + + common_types = self.header_types[outer_mult_index + 1 : inner_mult_index] + inner_loop_types = self.header_types[inner_mult_index + 1 :] + + measurements = [] + for i in range(outer_mult): + measurement: Dict[str, Any] = {"outer_index": i} + if common_types: + measurement.update(self._parse_generic_payload(common_types)) + + inner_mult = self._read_u16_from_buffer() + measurement["inner_mult"] = inner_mult + + inner_loops = [] + for j in range(inner_mult): + inner_loop_data = {"inner_index": j} + inner_loop_data.update(self._parse_generic_payload(inner_loop_types)) + inner_loops.append(inner_loop_data) + measurement["inner_loops"] = inner_loops + measurements.append(measurement) + result["measurements"] = measurements + + def _parse_single_mult(self, result: Dict[str, Any], inner_mult_index: int): + initial_types = self.header_types[:inner_mult_index] + if initial_types: + result.update(self._parse_generic_payload(initial_types)) + + mult = self._read_u16_from_buffer() + result["mult"] = mult + + inner_loop_types = self.header_types[inner_mult_index + 1 :] + rd_md_pairs = [] + for i in range(mult): + pair_data = {"index": i} + pair_data.update(self._parse_generic_payload(inner_loop_types)) + rd_md_pairs.append(pair_data) + result["rd_md_pairs"] = rd_md_pairs + + def _parse_linear(self, result: Dict[str, Any]): + while self.data_packets: + self.byte_buffer += self.data_packets.popleft().parsed_payload["data"] + + total_size = sum(t["size"] for t in self.header_types) + if len(self.byte_buffer) < total_size: + logger.warning( + f"Buffer size {len(self.byte_buffer)} less than expected {total_size} for linear parse in seq {self.seq_num}" + ) + + # Attempt to parse what's available + parsed = {} + offset = 0 + for i, type_info in enumerate(self.header_types): + size = type_info["size"] + if offset + size > len(self.byte_buffer): + logger.warning( + f"Payload too short for type {type_info['name']} at offset {offset} in linear parse seq {self.seq_num}" + ) + parsed[f"unparsed_tail_{offset}"] = self.byte_buffer[offset:].hex() + break + + data_bytes = self.byte_buffer[offset : offset + size] + fmt = type_info["format"] + name = type_info["name"] + try: + value = struct.unpack(fmt, data_bytes)[0] + except struct.error as e: + raise ValueError(f"Error unpacking {name} ({fmt}) at offset {offset}: {e}") + + if name == "x100U16TEMP": + value /= 100.0 + if name in ["x10U16RWL", "x10U16MWL"]: + value /= 10.0 + + parsed[f"{name}_{i}"] = value + offset += size + + result["parsed_data"] = parsed + self.byte_buffer = self.byte_buffer[offset:] + + +class SparkParser: + def __init__(self, data_bytes_list: List[bytes]): + self.all_packets: List[SparkPacket] = [] + for data_bytes in data_bytes_list: + try: + self.all_packets.append(SparkPacket(data_bytes)) + except Exception as e: + logger.error(f"Failed to parse packet from bytes: {data_bytes.hex()[:40]}... - {e}") + + self.sequences: Dict[int, List[SparkPacket]] = {} + for packet in self.all_packets: + self.sequences.setdefault(packet.seq_num, []).append(packet) + + for seq_num in self.sequences: + self.sequences[seq_num].sort(key=lambda p: p.indicator) # Headers before data + + def save_all_packets(self, filename="spark_all_packets.json"): + with open(filename, "w") as f: + json.dump([p.to_dict() for p in self.all_packets], f, indent=4) + logger.info(f"All {len(self.all_packets)} packets parsed and saved to {filename}") + + def process_all_sequences(self): + results: Dict[int, Any] = {} + for seq_num, packets_in_seq in self.sequences.items(): + logger.info(f"\nProcessing Sequence: {seq_num}") + if any(p.indicator == 136 for p in packets_in_seq): # Check for header + measurement_data = self._process_measurement_stream(packets_in_seq) + results[seq_num] = measurement_data + logger.info(f"Measurement stream for seq {seq_num} saved.") + else: + logger.info( + f"No header packet in sequence {seq_num}, skipping measurement stream processing." + ) + results[seq_num] = {"info": "No header packet found"} + return results + + def _process_measurement_stream(self, packets: List[SparkPacket]) -> List[Dict[str, Any]]: + seq_num = packets[0].seq_num + results = [] + + header_packets = sorted([p for p in packets if p.indicator == 136], key=lambda x: x.seq_num) + data_packets = deque( + sorted([p for p in packets if p.indicator == 131], key=lambda x: x.seq_num) + ) + + header_idx = 0 + while header_idx < len(header_packets): + header_packet = header_packets[header_idx] + header_type_codes = list(header_packet.parsed_payload["data"]) + header_type_names: List[str] = [] + for c in header_type_codes: + t_info = TDCL_DATA_TYPE_MAP.get(c) + if t_info: + header_type_names.append(t_info["name"]) + + if "U16MULT_H" in header_type_names: + logger.info(f"Found U16MULT_H in seq {seq_num}") + if not data_packets: + logger.warning(f"Missing data packet for U16MULT_H count in seq {seq_num}") + header_idx += 1 + continue + + try: + count_packet = data_packets.popleft() + count_payload = count_packet.parsed_payload["data"] + num_headers = _read_u16(count_payload, 0) + logger.info(f"U16MULT_H indicates {num_headers} measurement blocks.") + except Exception as e: + logger.error(f"Error reading U16MULT_H count in seq {seq_num}: {e}") + header_idx += 1 + continue + + grouped_results = [] + header_idx += 1 # Move past the U16MULT_H header + + for i in range(num_headers): + if header_idx < len(header_packets): + current_header = header_packets[header_idx] + logger.info(f"Processing grouped header {i + 1}/{num_headers} in seq {seq_num}") + block = MeasurementBlock(current_header, data_packets) + grouped_results.append(block.parse()) + header_idx += 1 + else: + logger.warning( + f"Expected {num_headers} headers, but only found {header_idx} in seq {seq_num}" + ) + break + results.append({"type": "grouped", "count": num_headers, "blocks": grouped_results}) + else: + logger.info(f"Processing standalone header in seq {seq_num}") + block = MeasurementBlock(header_packet, data_packets) + results.append({"type": "standalone", "block": block.parse()}) + header_idx += 1 + return results + + +def parse_single_spark_packet(data_bytes: bytes) -> Dict[str, Any]: + """Parses a single Spark packet bytes and returns a dictionary.""" + try: + packet = SparkPacket(data_bytes) + return packet.to_dict() + except Exception as e: + logger.error(f"Failed to parse single packet: {e}", exc_info=True) + return {"error": str(e), "hex_string": data_bytes.hex()} diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor.py b/pylabrobot/plate_reading/tecan/spark20m/spark_processor.py new file mode 100644 index 0000000000..2b11c6b531 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_processor.py @@ -0,0 +1,226 @@ +import logging +import math +import statistics + +from .spark_packet_parser import SparkParser + +logger = logging.getLogger(__name__) + + +class SparkProcessorBase: + def _parse_raw_data(self, raw_results): + parser = SparkParser(raw_results) + return parser.process_all_sequences() + + def _identify_sequences(self, parsed_data): + ref_seq_key = None + meas_seq_keys = [] + + for key, val in parsed_data.items(): + if isinstance(val, list) and len(val) > 0: + item = val[0] + if item.get("type") == "grouped": + ref_seq_key = key + elif item.get("type") == "standalone": + meas_seq_keys.append(key) + + meas_seq_keys.sort() + return ref_seq_key, meas_seq_keys + + def _safe_log10(self, x): + try: + return -math.log10(x) + except ValueError: + return "Error" + except TypeError: + return "Error" + + def _safe_div(self, n, d): + if d == 0: + return float("nan") + return n / d + + +class AbsorbanceProcessor(SparkProcessorBase): + def __init__(self): + pass + + def process(self, raw_results): + parsed_data = self._parse_raw_data(raw_results) + if not parsed_data: + logger.warning("No valid packets found in results.") + return [] + + ref_seq_key, meas_seq_keys = self._identify_sequences(parsed_data) + + if ref_seq_key is None or not meas_seq_keys: + logger.error("Could not identify Reference (grouped) and Measurement (standalone) sequences.") + logger.debug(f"Found sequences: {parsed_data.keys()}") + return [] + + try: + # Calculate average dark values from reference sequence (Block 0) + dark_block = parsed_data[ref_seq_key][0]["blocks"][0] + avg_rd_dark = statistics.mean(p["U16RD_DARK_0"] for p in dark_block["rd_md_pairs"]) + avg_md_dark = statistics.mean(p["U16MD_DARK_1"] for p in dark_block["rd_md_pairs"]) + + # Calculate average reference values from reference sequence (Block 1) + ref_block = parsed_data[ref_seq_key][0]["blocks"][1] + avg_rd_ref = statistics.mean(p["U16RD_0"] for p in ref_block["rd_md_pairs"]) + avg_md_ref = statistics.mean(p["U16MD_1"] for p in ref_block["rd_md_pairs"]) + + # Analyze each measurement sequence + final_results_list = [] + for seq_key in meas_seq_keys: + meas_block_entry = parsed_data[seq_key][0] + if meas_block_entry.get("type") == "standalone" and "block" in meas_block_entry: + measurements = meas_block_entry["block"]["measurements"] + log_ratios_row = [] + + for m in measurements: + loops = m["inner_loops"] + + ratios_md_rd = [] + ref_md_dark = avg_md_ref - avg_md_dark + ref_rd_dark = avg_rd_ref - avg_rd_dark + ref_ratio = self._safe_div(ref_md_dark, ref_rd_dark) + + for loop in loops: + sample_md = loop["U16MD_1"] + sample_rd = loop["U16RD_0"] + sample_md_dark = sample_md - avg_md_dark + sample_rd_dark = sample_rd - avg_rd_dark + sample_ratio = self._safe_div(sample_md_dark, sample_rd_dark) + ratios_md_rd.append(self._safe_div(sample_ratio, ref_ratio)) + + valid_ratios = [r for r in ratios_md_rd if not math.isnan(r)] + if valid_ratios: + avg_ratio = statistics.mean(valid_ratios) + log_ratio = self._safe_log10(avg_ratio) + else: + log_ratio = "Error" + + log_ratios_row.append(log_ratio) + final_results_list.append(log_ratios_row) + else: + logger.warning( + f"Skipping non-standalone or malformed measurement block entry in sequence {seq_key}" + ) + + return final_results_list + + except Exception as e: + logger.error(f"Error during calculation: {e}", exc_info=True) + return [] + + +class FluorescenceProcessor(SparkProcessorBase): + def process(self, raw_results): + parsed_data = self._parse_raw_data(raw_results) + if not parsed_data: + logger.warning("No valid packets found in results.") + return [] + + ref_seq_key, meas_seq_keys = self._identify_sequences(parsed_data) + + if ref_seq_key is None: + logger.error("Calibration sequence not found.") + return [] + + if not meas_seq_keys: + logger.error("Measurement sequence not found.") + return [] + + logger.info( + f"Using Sequence {ref_seq_key} for Calibration and Sequences {meas_seq_keys} for Measurements." + ) + + try: + # Extract dark values + # Assuming grouped sequence block 0 is dark + cal_seq_data = parsed_data[ref_seq_key][0] + block0 = cal_seq_data["blocks"][0] + + # Check if it has dark headers + if any("DARK" in t for t in block0.get("header_types", [])): + dark_pairs = block0["rd_md_pairs"] + signal_dark_values = [] + ref_dark_values = [] + + for pair in dark_pairs: + md_key = next((k for k in pair.keys() if "U16MD" in k), None) + rd_key = next((k for k in pair.keys() if "U16RD" in k), None) + if md_key and rd_key: + signal_dark_values.append(pair[md_key]) + ref_dark_values.append(pair[rd_key]) + + if not signal_dark_values or not ref_dark_values: + logger.error("Could not extract Dark values.") + return [] + + signalDark = sum(signal_dark_values) / len(signal_dark_values) + refDark = sum(ref_dark_values) / len(ref_dark_values) + else: + logger.error("Block 0 does not look like Dark calibration.") + return [] + + # Extract bright values + block1 = cal_seq_data["blocks"][1] + bright_pairs = block1["rd_md_pairs"] + ref_bright_values = [] + for pair in bright_pairs: + rd_key = next((k for k in pair.keys() if "U16RD" in k), None) + if rd_key: + ref_bright_values.append(pair[rd_key]) + + if not ref_bright_values: + logger.error("Could not extract Bright Reference values.") + return [] + + refBright = sum(ref_bright_values) / len(ref_bright_values) + + # Calculate K + k_val = (refBright - refDark) / (65536.0 - signalDark) * 65536.0 * 1.0 + + logger.debug( + f"signalDark: {signalDark}, refDark: {refDark}, refBright: {refBright}, K: {k_val}" + ) + + # Calculate RFU + final_results_list = [] + for seq_id in meas_seq_keys: + meas_seq_data = parsed_data[seq_id][0] + measurements = meas_seq_data["block"]["measurements"] + rfu_row = [] + + for measurement in measurements: + inner_loops = measurement["inner_loops"] + + raw_signal_values = [] + raw_ref_signal_values = [] + for loop in inner_loops: + md_key = next((k for k in loop.keys() if "U16MD" in k), None) + rd_key = next((k for k in loop.keys() if "U16RD" in k), None) + if md_key and rd_key: + raw_signal_values.append(loop[md_key]) + raw_ref_signal_values.append(loop[rd_key]) + + if not raw_signal_values or not raw_ref_signal_values: + logger.warning("Skipping measurement due to missing data.") + rfu_row.append("Error") + continue + + rawSignal = sum(raw_signal_values) / len(raw_signal_values) + rawRefSignal = sum(raw_ref_signal_values) / len(raw_ref_signal_values) + + # RFU Calculation + rfu = (rawSignal - signalDark) / (rawRefSignal - refDark) * k_val + rfu_row.append(rfu) + + final_results_list.append(rfu_row) + + return final_results_list + + except Exception as e: + logger.error(f"Error during calculation: {e}", exc_info=True) + return [] diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py b/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py new file mode 100644 index 0000000000..1c3dfea76a --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py @@ -0,0 +1,348 @@ +import logging +import math +import unittest +from unittest.mock import patch + +# Configure logging to avoid pollution during tests +from pylabrobot.plate_reading.tecan.spark20m.spark_processor import ( + AbsorbanceProcessor, + FluorescenceProcessor, +) + +logging.basicConfig(level=logging.CRITICAL) + + +class TestAbsorbanceProcessor(unittest.TestCase): + def setUp(self): + self.processor = AbsorbanceProcessor() + + def test_process_success(self): + # Mock _parse_raw_data to return a controlled structure + # We need a reference sequence (grouped) and a measurement sequence (standalone) + + # Reference Data Setup + # Block 0: Dark + # avg_rd_dark = 100 + # avg_md_dark = 50 + dark_pairs = [ + {"U16RD_DARK_0": 100, "U16MD_DARK_1": 50}, + {"U16RD_DARK_0": 100, "U16MD_DARK_1": 50}, + ] + + # Block 1: Reference + # avg_rd_ref = 1000 + # avg_md_ref = 500 + ref_pairs = [{"U16RD_0": 1000, "U16MD_1": 500}, {"U16RD_0": 1000, "U16MD_1": 500}] + + # Calculations for Reference: + # ref_md_dark = 500 - 50 = 450 + # ref_rd_dark = 1000 - 100 = 900 + # ref_ratio_h9 = 450 / 900 = 0.5 + + parsed_data = { + "SEQ_REF": [ + {"type": "grouped", "blocks": [{"rd_md_pairs": dark_pairs}, {"rd_md_pairs": ref_pairs}]} + ], + "SEQ_MEAS": [ + { + "type": "standalone", + "block": { + "measurements": [ + { + # Measurement 1 + # Sample 1 + # sample_md = 250, sample_rd = 2000 + # sample_md_dark = 250 - 50 = 200 + # sample_rd_dark = 2000 - 100 = 1900 + # sample_ratio = 200 / 1900 = 0.105263... + # ratio_final = sample_ratio / ref_ratio_h9 = (2/19) / 0.5 = 4/19 = 0.210526... + # h9 = -log10(0.210526...) + "inner_loops": [{"U16MD_1": 250, "U16RD_0": 2000}] + } + ] + }, + } + ], + } + + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0]), 1) + + expected_ratio = (200 / 1900) / 0.5 + expected_h9 = -math.log10(expected_ratio) + + self.assertAlmostEqual(results[0][0], expected_h9, places=5) + + def test_process_missing_reference(self): + # Only standalone sequences, no grouped reference + parsed_data = {"SEQ_MEAS": [{"type": "standalone", "block": {"measurements": []}}]} + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + + self.assertEqual(results, []) + + def test_process_empty_data(self): + with patch.object(self.processor, "_parse_raw_data", return_value={}): + results = self.processor.process([]) + self.assertEqual(results, []) + + def test_zero_division_protection(self): + # Setup data that would cause division by zero if not handled + # E.g. ref_rd_dark = 0 => avg_rd_ref = avg_rd_dark + + dark_pairs = [{"U16RD_DARK_0": 100, "U16MD_DARK_1": 50}] + ref_pairs = [{"U16RD_0": 100, "U16MD_1": 500}] # avg_rd_ref = 100 + + parsed_data = { + "SEQ_REF": [ + {"type": "grouped", "blocks": [{"rd_md_pairs": dark_pairs}, {"rd_md_pairs": ref_pairs}]} + ], + "SEQ_MEAS": [ + { + "type": "standalone", + "block": {"measurements": [{"inner_loops": [{"U16MD_1": 250, "U16RD_0": 2000}]}]}, + } + ], + } + + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + + # Should result in Error or handled gracefully + # If ref_ratio_h9 is NaN (due to 0 division), then final ratio is NaN, so h9 is Error + self.assertEqual(results[0][0], "Error") + + def test_process_real_data(self): + abs = [ + b"\x88\t\x00\x01\x13\x93", + b"\x83\t\x00\x02\x00\x02\x8a", + b"\x88\t\x00\x06\x0f\x0c\x1a\x12\x15\x16\x8f", + b"\x83\t\x00\t\x01\x00\x00\x07\x7f\x00\x1e\x00\x1e\xfa", + b"\x83\t\x00P\x02'\x01\x0e\x02\x1d\x01\x0e\x02\x1d\x01\x0f\x02$\x01\x0e\x02 \x01\x10\x02'\x01\x0f\x02!\x01\x0f\x02\"\x01\x0e\x02 \x01\x0f\x02\x1a\x01\x0e\x02!\x01\x10\x02\x18\x01\x0f\x02!\x01\x0f\x02\x1e\x01\x0f\x02\x1d\x01\x0e\x02\x17\x01\x0f\x02\x1d\x01\x0f\x02\x1f\x01\x10\x02\x1e\x01\x0e\x02$\x01\x10\xd2", + b"\x83\t\x00(\x02%\x01\x0f\x02\x1f\x01\x0f\x02\x1f\x01\x0e\x02 \x01\x0e\x02\x1f\x01\x11\x02\x1b\x01\x0e\x02\x1d\x01\x0f\x02\x1f\x01\x0f\x02\x1e\x01\x0f\x02&\x01\x0f\x86", + b"\x88\t\x00\x08\x0f\x0c\x11\x18\x1a\x12\x00\x02\x89", + b"\x83\t\x00\r\x01\x00\x00\t\xce\x00/\x00\x17\x00\x1e\x00\x1ey", + b"\x83\t\x00P\x9fI\xa2\x13\xa06\xa3\x03\xa0\x07\xa2\xe2\xa0\x1d\xa2\xf6\xa0 \xa2\xe0\xa0z\xa3J\xa0\xa4\xa3y\x9f\x94\xa2c\xa0\xff\xa3\xce\xa2]\xa5.\xa2\x7f\xa5^\xa3#\xa6\x04\xa4A\xa7+\xa4\xeb\xa7\xd1\xa3W\xa60\xa2\xac\xa5|\xa1\xa2\xa4t\xa3-\xa6\x06\xa2\xf5\xa5\xcc\xa1E\xa4)m", + b"\x83\t\x00(\xa0\x07\xa2\xef\xa0\xf7\xa3\xc1\xa08\xa3\x06\xa0m\xa37\xa2\x93\xa5a\xa2\xce\xa5\xb3\xa3i\xa6I\xa3W\xa6/\xa3\xa1\xa6\x84\xa0\xcf\xa3\xaa\x88", + b"\x88\n\x00\x06\x12\x0f\x0c\x12\x00\x02\x85", + b"\x83\n\x00\t\x00\x0c\x01\x00\x00\x11U\x00\n\xc3", + b"\x83\n\x00(\xa0\x11\x01/\x9e\xef\x01.\xa0$\x01.\xa0x\x01/\xa0K\x01/\xa0\xc1\x010\xa0\x1c\x01/\xa1}\x010\xa1\x9c\x01/\xa2\xe5\x010\xb2", + b"\x83\n\x00\x07\x01\x00\x00\x13p\x00\n\xe6", + b"\x83\n\x00(\xa25\x92\x16\xa1\x9d\x91\x95\xa3`\x93\x1c\xa2\xc1\x92\x98\xa4\x1d\x93\xce\xa4\xb4\x94h\xa3\xbc\x93|\xa5\xec\x95q\xa5)\x94\xcb\xa7\xa9\x97\x08\xb8", + b"\x83\n\x00\x07\x01\x00\x00\x15S\x00\n\xc3", + b"\x83\n\x00(\xa4M\x8fk\xa4\xbc\x8f\xd2\xa4\xc4\x8f\xe9\xa2B\x8d\xa9\xa4n\x8f\x8f\xa4~\x8f\x8e\xa5\x88\x90z\xa5\xc2\x90\xb6\xa6\x8e\x91x\xa9\xea\x94h\xc2", + b"\x83\n\x00\x07\x01\x00\x00\x17\x0c\x00\n\x9e", + b"\x83\n\x00(\xa1\xc9\x8bU\xa0j\x8a$\xa3 \x8cV\xa1\xc5\x8b(\xa4\xbe\x8d\xd2\xa3\xe3\x8d'\xa4\x8f\x8d\xd3\xa4\xd7\x8e\x1d\xa4\xb4\x8d\xed\xa5\x96\x8e\x99\x83", + b"\x83\n\x00\x07\x01\x00\x00\x18\xa8\x00\n5", + b"\x83\n\x00(\xa2\x84\x90\x90\xa2\xab\x90\xaa\xa1|\x8f\x9c\xa3\x9f\x91l\xa4\xb1\x92r\xa3>\x912\xa36\x916\xa7y\x95\t\xa7\xb3\x953\xa6Y\x94\x03\xda", + b"\x83\n\x00\x07\x01\x00\x00\x1aD\x00\n\xdb", + b"\x83\n\x00(\xa2\x1b\x8bp\xa2\xc7\x8b\xf8\xa4X\x8dT\xa4Z\x8dh\xa3I\x8c\x9c\xa3Y\x8c\xb3\xa4\xbb\x8d\xbf\xa6\x15\x8e\xd5\xa6\xd3\x8fq\xa7l\x90\x12\xf3", + b"\x83\n\x00\x07\x01\x00\x00\x1b\xe0\x00\n~", + b"\x83\n\x00(\xa2\xf2\x903\xa3\x18\x90S\xa2\xcb\x90\x0e\xa3\x8a\x90\xb4\xa2\xea\x90,\xa4\x13\x91F\xa6\xcc\x93\xa9\xa7\xf4\x94\xb7\xa6!\x93\x1d\xa6\x1c\x93\tM", + b"\x83\n\x00\x07\x01\x00\x00\x1d\xc3\x00\n[", + b"\x83\n\x00(\xa2c\x88\r\xa2\xb3\x88y\xa2$\x87\xf3\xa3\x16\x88\xb9\xa4/\x89\x97\xa4\x0f\x89\x85\xa5\xb5\x8a\xf7\xa5k\x8a\xb9\xa98\x8d\xe1\xa5\xe0\x8b\x01\xe3", + b"\x83\n\x00\x07\x01\x00\x00\x1fz\x00\n\xe0", + b"\x83\n\x00(\xa2\xb4\x8f/\xa3i\x8f\xcc\xa4v\x90\xa9\xa4&\x90A\xa3\x05\x8f3\xa6\x07\x91\xfb\xa5\xef\x91\xe3\xa5\x0c\x91!\xa7[\x93)\xa6\xc0\x92\x9f\xfb", + b"\x83\n\x00\x07\x01\x00\x00 \xf6\x00\nS", + b"\x83\n\x00(\xa2\xed\x91T\xa3\x98\x92\x00\xa2I\x90\xc5\xa3$\x91\x9a\xa2\xb9\x914\xa4N\x92\xa0\xa6e\x94\x88\xa6O\x94{\xa7\x07\x95\x1e\xa5)\x93vM", + b'\x83\n\x00\x07\x01\x00\x00"\xab\x00\n\x0c', + b"\x83\n\x00(\xa3\x13\x8e\xee\xa0\x7f\x8c\xa0\xa1O\x8d^\xa5\x08\x90\x9e\xa1\x81\x8d\x94\xa5\x03\x90\xab\xa6\x1e\x91\xa2\xa5D\x90\xe6\xa7S\x92\xae\xa6G\x91\xc9\xd5", + b"\x83\n\x00\x07\x01\x00\x00$\x90\x00\n1", + b"\x83\n\x00(\xa0\xe7\x90\xd5\xa1n\x91J\xa31\x92\xe0\xa5t\x94\xe0\xa3\xd1\x93{\xa3\xdb\x93\x8b\xa5\xc5\x95>\xa5O\x94\xd8\xa7\x07\x96s\xa7}\x96\xd7\xbb", + b"\x88\x0b\x00\x06\x12\x0f\x0c\x12\x00\x02\x84", + b"\x83\x0b\x00\t\x00\x0c\x01\x00\x00(\xba\x00\n\x14", + b"\x83\x0b\x00(\x9f\xc7\x01$\x9f\xd9\x01&\xa0\xb2\x01%\xa2\xb8\x01'\xa0\xbc\x01%\xa2\xad\x01%\xa26\x01%\xa1\xca\x01%\xa2C\x01&\xa4|\x01&c", + b"\x83\x0b\x00\x07\x01\x00\x00*\r\x00\n\xa3", + b"\x83\x0b\x00(\x9e\xd1\x01&\x9f\xf8\x01&\xa2\x07\x01)\xa1\t\x01)\xa1\x94\x01'\xa1\xcb\x01#\xa2X\x01$\xa3\xce\x01%\xa5\x10\x01'\xa5\xc3\x01'\x9b", + b"\x83\x0b\x00\x07\x01\x00\x00+\xa5\x00\n\n", + b"\x83\x0b\x00(\xa0\xa5\x01'\xa2\x8a\x01%\xa0<\x01'\xa0\xc8\x01(\xa0l\x01(\xa13\x01'\xa2\xf2\x01'\xa3\xd2\x01&\xa4\x85\x01&\xa4\xfb\x01'z", + b"\x83\x0b\x00\x07\x01\x00\x00-\x86\x00\n/", + b"\x83\x0b\x00(\xa0f\x01&\xa0Y\x01&\xa0\xea\x01'\xa2\x08\x01&\x9f\xa4\x01&\xa3\x95\x01'\xa2\xa7\x01'\xa5\r\x01(\xa5\xb6\x01'\xa4\x8b\x01'\xec", + b"\x83\x0b\x00\x07\x01\x00\x00/=\x00\n\x96", + b"\x83\x0b\x00(\x9f\xe3\x01&\xa0k\x01$\x9e\xfd\x01%\xa1\xac\x01'\x9e\xf8\x01'\xa2g\x01&\xa6G\x01&\xa4\xa6\x01%\xa2\xb4\x01%\xa3\xf4\x01&y", + b"\x83\x0b\x00\x07\x01\x00\x000\xd5\x00\na", + b"\x83\x0b\x00(\xa0L\x01%\xa0\xc0\x01$\x9f\x01\x01$\xa1\xac\x01#\xa2\x1a\x01#\xa1\x93\x01&\xa3\x88\x01%\xa4\xbb\x01&\xa5\xf7\x01&\xa4]\x01$\xa8", + b"\x83\x0b\x00\x07\x01\x00\x002o\x00\n\xd9", + b"\x83\x0b\x00(\xa1Y\x01$\xa0#\x01#\xa0\xad\x01%\xa0K\x01%\xa1\xa3\x01'\xa2\xbb\x01&\xa6\xad\x01'\xa5\x00\x01%\xa6\x89\x01%\xa2\xf7\x01$\xf7", + b"\x83\x0b\x00\x07\x01\x00\x004R\x00\n\xe2", + b"\x83\x0b\x00(\xa0&\x01$\xa1(\x01#\xa1\x1f\x01#\xa2o\x01%\xa2\r\x01%\xa4\x0f\x01$\xa3\xe5\x01%\xa5,\x01$\xa5z\x01&\xa5\x0f\x01&c", + b"\x83\x0b\x00\x07\x01\x00\x006\n\x00\n\xb8", + b"\x83\x0b\x00(\xa2\x07\x91\xab\xa5\r\x94_\xa4;\x93\xa8\xa5\x8d\x94\xc9\xa4&\x93\x96\xa5:\x94\x8f\xa8C\x97Y\xa7\x8c\x96\xb8\xa5,\x94\x86\xa4\xb1\x94\x1c\xa8", + b"\x83\x0b\x00\x07\x01\x00\x007\xa4\x00\n\x17", + b"\x83\x0b\x00(\xa2\xec\x92\xec\xa4,\x94\x14\xa3)\x93.\xa3p\x93_\xa1\x9d\x91\xce\xa5\x15\x94\xde\xa4\xe7\x94\xba\xa6\xd0\x96n\xa4\x11\x93\xf4\xa4\x92\x94a\xdb", + b"\x83\x0b\x00\x07\x01\x00\x009<\x00\n\x81", + b"\x83\x0b\x00(\xa3\x05\x92\xaf\xa2A\x92\x17\xa2\xa9\x92i\xa2/\x91\xf5\xa4?\x93\xd4\xa4\xe0\x94l\xa5\x10\x94\x98\xa6\xe6\x96B\xa6\x9a\x95\xe5\xa6;\x95\x9a\xd7", + b"\x83\x0b\x00\x07\x01\x00\x00:\xd4\x00\nj", + b"\x83\x0b\x00(\xa2:\x91\xe1\xa1\x9e\x91[\xa1t\x91'\xa1\xc7\x91y\xa3o\x93\x08\xa4V\x93\xdc\xa7%\x96k\xa5q\x94\xe9\xa6\x07\x95f\xa3\xe5\x93\x8df", + ] + absp = AbsorbanceProcessor() + proc = absp.process(abs) + res = [ + [ + 3.11063277753661, + 0.05336257720528929, + 0.06702104600211652, + 0.07299936992281636, + 0.05887417419669415, + 0.0734290764001552, + 0.061013133423609846, + 0.08478106298852653, + 0.06390239296863666, + 0.057396722793976014, + 0.06527130757756598, + 0.05359379934802616, + ], + [ + 3.268966569235883, + 3.2532244522041207, + 3.243948508296082, + 3.2467027122313525, + 3.263830898919421, + 3.286531371816557, + 3.277737476157588, + 3.290353848437903, + 0.054148473423301896, + 0.052819168592559695, + 0.05369339271038759, + 0.05389217987680245, + ], + ] + assert proc == res + + +class TestFluorescenceProcessor(unittest.TestCase): + def setUp(self): + self.processor = FluorescenceProcessor() + + def test_process_success(self): + # Calibration Sequence (Grouped, count=2) + # Block 0: Dark + # signalDark = 50, refDark = 100 + dark_block = { + "header_types": ["U16RD_DARK", "U16MD_DARK"], # Mimic header check + "rd_md_pairs": [{"U16MD_DARK_1": 50, "U16RD_DARK_0": 100}], + } + # Block 1: Bright + # refBright = 50000 + bright_block = {"rd_md_pairs": [{"U16RD_0": 50000}]} + + # K Calculation: + # K = (50000 - 100) / (65536 - 50) * 65536 + # K = 49900 / 65486 * 65536 ~= 0.76199... * 65536 ~= 49938 + k_val = (50000 - 100) / (65536.0 - 50) * 65536.0 + + # Measurement Sequence + # rawSignal = 20000, rawRefSignal = 30000 + # rfu = (20000 - 50) / (30000 - 100) * K + # rfu = 19950 / 29900 * K ~= 0.6672 * K + meas_block = { + "structure_type": "nested_mult", + "measurements": [{"inner_loops": [{"U16MD_1": 20000, "U16RD_0": 30000}]}], + } + + parsed_data = { + "SEQ_CAL": [{"type": "grouped", "count": 2, "blocks": [dark_block, bright_block]}], + "SEQ_MEAS": [{"type": "standalone", "block": meas_block}], + } + + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0]), 1) + + expected_rfu = (20000 - 50) / (30000 - 100) * k_val + self.assertAlmostEqual(results[0][0], expected_rfu, places=3) + + def test_process_missing_calibration(self): + parsed_data = { + "SEQ_MEAS": [ + {"type": "standalone", "block": {"structure_type": "nested_mult", "measurements": []}} + ] + } + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + self.assertEqual(results, []) + + def test_process_invalid_dark_block(self): + # Missing 'DARK' in header_types + dark_block = { + "header_types": ["U16RD", "U16MD"], + "rd_md_pairs": [{"U16MD_1": 50, "U16RD_0": 100}], + } + bright_block = {"rd_md_pairs": [{"U16RD_0": 50000}]} + + parsed_data = { + "SEQ_CAL": [{"type": "grouped", "count": 2, "blocks": [dark_block, bright_block]}] + } + + with patch.object(self.processor, "_parse_raw_data", return_value=parsed_data): + results = self.processor.process([]) + + # Should return empty list because Block 0 does not look like Dark calibration + self.assertEqual(results, []) + + def test_process_real_data(self): + fluo = [ + b"\x88\x0f\x00\x01\x13\x95", + b"\x83\x0f\x00\x02\x00\x02\x8c", + b"\x88\x0f\x00\x07\x0f\x0c\x0b\x1a\x12\x15\x16\x83", + b"\x83\x0f\x00\x0b\x01\x00\x00\x0b.\x12\xf2\x00Z\x00ZC", + b"\x83\x0f\x00\x04\x00r\rf\x91", + b"\x83\x0f\x01d\x00q\rc\x00r\rd\x00r\rr\x00q\rk\x00q\rd\x00q\rg\x00r\rd\x00q\re\x00q\rd\x00q\rn\x00q\re\x00q\rk\x00r\rd\x00q\re\x00r\rg\x00q\re\x00q\re\x00r\re\x00r\rd\x00q\re\x00q\rd\x00r\rd\x00q\rc\x00r\rd\x00r\rc\x00q\ro\x00q\rd\x00q\rc\x00r\re\x00r\re\x00q\rh\x00r\re\x00p\rf\x00q\re\x00q\rc\x00r\re\x00r\rf\x00q\rh\x00p\rd\x00r\rd\x00q\rg\x00r\rd\x00r\rd\x00q\rc\x00q\rd\x00r\re\x00r\re\x00q\rl\x00r\rd\x00r\rf\x00q\rc\x00r\rc\x00q\rf\x00q\re\x00q\rd\x00r\re\x00q\rd\x00q\rd\x00q\re\x00q\re\x00p\re\x00q\rc\x00p\rd\x00q\rf\x00r\rd\x00q\rf\x00r\re\x00r\re\x00s\rd\x00r\rd\x00q\rd\x00q\rd\x00r\re\x00r\rd\x00q\rd\x00q\rp\x00q\re\x00r\re\x00q\rf\x00q\rg\x00q\rf\x00r\rc\x00q\rd\x00p\rq\x00r\re\x00r\rd\x00q\re\x00q\rc\x00q\rd\xeb", + b"\x88\x0f\x00\x07\x0f\x0c\x0b\x11\x1a\x12\x00\x91", + b"\x83\x0f\x00\r\x01\x00\x00\x15+\x12\xf2\x00\x07\x00Z\x00ZY", + b"\x83\x0f\x00\x02\xa5<\x17", + b"\x83\x0f\x00\xb2\xa9k\xa8=\xa7\xdb\xa80\xa8\xeb\xa89\xa9\x97\xa8\x1c\xa9\x82\xa8\xc9\xa9\xcb\xa98\xa9\x06\xaaJ\xa9\xcf\xa9\xca\xa9\xd2\xa7\xd6\xa8X\xaa\x9b\xa9N\xaa\x1c\xaad\xaab\xa9\xb3\xac\xca\xab\x03\xaa'\xac\xaf\xaa\x99\xaa\xee\xa9\xf4\xa9\xe3\xa9~\xa9\xec\xab\xa5\xaa1\xaa\x81\xa8\x81\xa9\xe5\xa9\xa2\xa9\xa9\xa8\xa7\xa9-\xa9k\xa8\x7f\xaap\xa9\x0f\xaa\xc3\xa9\xd3\xab\x05\xab\x08\xaap\xaa\x83\xaaZ\xabU\xa9[\xaa\xba\xa9x\xaaN\xaa\xc8\xa9\xca\xa9\xc7\xaa\x97\xa8\x9e\xab\x1e\xaa`\xaa\x95\xaa,\xacG\xa9G\xab\x91\xab\xbb\xab*\xaaJ\xaa\x9b\xabU\xab\xec\xaak\xa5\xbd\xac\xd1\xaa\xe1\xa9y\xaai\xa9A\xaa_\xaao\xa8.\xa9\xc6\xbd", + b"\x88\x10\x00\x08\x0f\x0b\x17\x12\x0c\x12\x00\x02\x8d", + b"\x83\x10\x00\r\x01\x12\xf2\x14\xe6\x00\x0c\x00\x00\x1fN\x00\x1e\xce", + b"\x83\x10\x00x\xa5w\xba\x86\xa8l\xc0Z\xa8\x05\xba\xc8\xa7\xd3\xc0\xc5\xa8-\xbf/\xa8H\xbc\x9f\xa8[\xb9\xb1\xa87\xbdi\xa8n\xbcE\xa8\xee\xbe\x1f\xa9\x91\xc1\xae\xa9\x14\xbe\xd7\xa8\r\xbe(\xa8\xa5\xc0\x03\xa8l\xbb\x8c\xa9\x8c\xc0*\xa7\xbf\xber\xaa\x00\xbc\x02\xaa,\xbd|\xa9\x93\xbcQ\xaa\xd0\xbcD\xa9\x0f\xc1\x83\xa8\x81\xbe\x11\xa9]\xbb\xa8\xa9-\xbbB\xa7}\xc1\x93\xa9x\xba\xb2\xabg\xc0E\xa8u\xba\xef\xa96\xbey\xeb", + b"\x83\x10\x00\x06\x00\x00#\x99\x00\x1e1", + b"\x83\x10\x00x\xa7\x16\xbd\xee\xa9\x19\xc1\xe2\xa7\xd6\xbd\xe5\xa8\xc0\xbd\xc1\xa7\x9f\xbeR\xa8\xbd\xc0\x92\xa7C\xbf\x96\xa9E\xbe\x9d\xa9\x1e\xbe\xf5\xa9h\xbc\x12\xa8\x8f\xbb\xcd\xa7w\xbe\xf4\xaa\x10\xc1^\xa7t\xbbs\xa9\x1c\xba\xe2\xaa\x8a\xbf\xf7\xa9\x95\xbf\x9d\xa9\x99\xbb\xd1\xa9\xa1\xbe\xda\xa9\x8c\xb7.\xaaT\xbc\x88\xaa\x12\xbaI\xa9A\xbeB\xa9\x81\xbd3\xa9\xb9\xba\xf8\xa9\xba\xc1\n\xaa6\xbf\\\xa9\x1b\xc1\xe3\xaa\xb7\xbc\xa5\xa7\xb8\xc0t\xb8", + b"\x83\x10\x00\x06\x00\x00%\x9b\x00\x1e5", + b"\x83\x10\x00x\xa7$\xba\x8e\xa8\xf9\xb7\x8a\xa8\x01\xbd\xb7\xaa!\xb6\xf7\xa9%\xb58\xa7\x15\xb9\x1a\xaa^\xb7\xc4\xa7\xea\xbc\xa6\xa9\xa2\xb9\xfb\xa9\xcb\xb7\x0e\xaa\x19\xb9a\xab\x11\xb9\xb3\xa9\x8e\xb9\xf5\xa9\r\xb6\xfe\xa9\xd5\xb8\xff\xaa\t\xb8E\xa9h\xbb\xb9\xab\xdf\xbd|\xa9M\xb8\xfc\xaaQ\xb8\x84\xa9\n\xb9G\xa9U\xbb\x18\xaa\x81\xc1\xa0\xab\x15\xb7\xdb\xaa\xdd\xb8X\xaar\xba\xd3\xa8\xb0\xbb5\xa9,\xb7;\xaa\x9d\xb9\xe2\xabO\xbe\xaa\x9c", + b"\x83\x10\x00\x06\x00\x00(\x05\x00\x1e\xa6", + b"\x83\x10\x00x\xa6\x8a\xb5\xdc\xa8\xeb\xb3>\xa9\xb7\xb7\xfc\xa8\x14\xb6;\xa8z\xb7\x1d\xa8\xe9\xba\xe0\xa8\xaf\xb7\x84\xa9)\xb5a\xa8\x15\xb9\xa6\xab)\xb4\x84\xa9h\xb5\xa1\xa9\x9c\xb3\x04\xa7\xe2\xb4,\xa8\xbd\xb5\xd0\xaa\x07\xb71\xa9r\xb5t\xa9\x83\xb5\xae\xa8\x1f\xb4\xe7\xa8}\xb3k\xab\xa2\xb5\x86\xa7F\xae\x0e\xa7\x02\xb5\xb1\xa9f\xb4\xb7\xab\xe2\xb6\x8b\xaa<\xb66\xaa\x0f\xb8!\xa8\x89\xb9T\xaa\xd0\xb4\xd8\xab\x00\xb3\\\xa9\x89\xb3\x8e\xcf", + b"\x83\x10\x00\x06\x00\x00*\x07\x00\x1e\xa6", + b"\x83\x10\x00x\xa7\x18\xbf\xe0\xaa^\xbf`\xa9`\xbfs\xa8\x0f\xbbL\xa8v\xb9h\xa9.\xb9\x11\xa6V\xbe\xa7\xa7C\xbcN\xa7\xf9\xbc|\xa9\x15\xbe\x0e\xa98\xba\x13\xa9\x95\xbe\x9d\xaa\x16\xbb\xde\xaav\xbe \xa8\x18\xba\xea\xaa3\xbb\x92\xa8\x7f\xbe\xce\xa9\x15\xba\xb0\xa9\xd3\xba\x06\xa9\x11\xbc\xa9\xaa\xdd\xc3\x04\xa8\xc3\xbc\xde\xa6\x8c\xb9\xf6\xa9\xc3\xbc<\xa90\xbd\xa3\xaa\xfc\xbe\x8f\xaa\x1e\xbd\xdf\xa9\xd7\xbe\x84\xaa\xc8\xbcs\xa8G\xbc\xac\xb8", + b"\x83\x10\x00\x06\x00\x00,\x06\x00\x1e\xa1", + b"\x83\x10\x00x\xa7\xb3\xbcV\xa9\x1c\xbf\xbb\xa7K\xbdq\xa9\x9e\xbc`\xa8X\xbaw\xa7\x12\xb8\xe5\xa9f\xc0\xb3\xa7\x1e\xbcx\xaa!\xbcR\xaa\xa2\xbb\x15\xaa\x01\xbd\xd3\xa7\xd1\xbai\xa9(\xb8\x8d\xa9\xf2\xbd\r\xa9\x91\xb7b\xaa\x15\xbf\xa6\xa7\x04\xc2\xea\xaaP\xbc|\xa9\x9c\xbb^\xa8\x01\xbf/\xabD\xba\xbe\xaa\xaf\xbaS\xa8\x8e\xbd\x0b\xa8\x95\xba?\xa9\xc7\xb9t\xa9v\xb8_\xaa+\xbad\xa9H\xbbM\xa9\xdb\xb8\x92\xa9z\xbaJ\x13", + b"\x83\x10\x00\x06\x00\x00.\x06\x00\x1e\xa3", + b"\x83\x10\x00x\xa5\x1b\xbd\x9b\xa7\xf9\xbc\x19\xa9k\xbc\xba\xaa\x92\xbe\xc7\xa8P\xbfj\xa8\xc4\xc0\x92\xa8\xbf\xbb_\xa9\xc4\xbb\xa1\xa9\xa0\xbf\xcf\xa8\xdf\xc3L\xa9\x92\xbf\xe3\xa9H\xbc}\xa9@\xbc\x1e\xa8k\xbe\xe5\xa9\xf4\xbf\xcb\xa8\xf3\xbc\x95\xa9\x89\xbd\x05\xa9U\xbd\xa1\xaac\xbcS\xa7k\xbc$\xa9\xee\xbc\x85\xa6\xec\xbd\r\xa7\x83\xbag\xa8R\xba\xc9\xa9\xe3\xbb\xee\xa7\x8b\xbeP\xa8w\xba.\xaa\x02\xbe\r\xa9O\xbc\x90\xa9\xef\xbb\x08\x98", + b"\x83\x10\x00\x06\x00\x000t\x00\x1e\xcf", + b"\x83\x10\x00x\xa5\x90\xc1\x0f\xa8\x9a\xc0o\xa8C\xc2\x12\xa6\xa7\xc2<\xa7\x88\xc2\xa7\xaa.\xc2\x18\xa7a\xc0\x00\xa9]\xc0\x85\xa8\xb7\xc0\x0c\xa9\xef\xc2\xe5\xa8\xf1\xc2_\xa8^\xc2\x1a\xa9\x89\xc0\xee\xac\x0f\xc2%\xa9*\xc2\x0f\xa9\x03\xc2}\xaa\xab\xc5 \xa9\xbf\xc3\t\xa8\xd1\xc3\xcc\xa9\xd7\xc7\x06\xa9c\xc3W\xaa\x14\xc1\x99\xaa\x8b\xc3K\xa7V\xc1r\xa8\xb3\xc3\xab\xa8\x1a\xc0\xb6\xaa\x07\xc2\x94\xaa2\xba\xf2\xa9\xe0\xc2\xf0\xaa@\xc5\x81H", + b"\x83\x10\x00\x06\x00\x002t\x00\x1e\xcd", + b"\x83\x10\x00x\xa4\xaa\xba\x07\xa9C\xba\xfc\xa7\\\xbb\xff\xa8|\xbb\x9a\xa8\x82\xbc\x0c\xa9\x99\xb7\xa4\xa9\x97\xbe\xd4\xa9\xef\xba\x9d\xa6\xed\xbc\xec\xa9\xd5\xba\x8d\xa8v\xb9\x08\xa9\x98\xb9\x07\xa85\xb9\xaf\xa8\xb6\xbe\xbf\xa7\x0c\xba\x85\xa8q\xc2n\xa8\x10\xbe\xbf\xa8\x19\xba\xa9\xa8R\xbc\xa2\xa8\x1e\xb9\x80\xa8\x1c\xbc\xbf\xaa@\xbc\xdc\xabi\xbb\xf3\xa9\x8f\xbb\x8e\xa9\x0f\xbcM\xa8\xb0\xc1\xca\xaa:\xbc*\xa9\xa5\xbai\xa8\xcb\xbd\x03\xa9\x00\xc0BH", + b"\x83\x10\x00\x06\x00\x004t\x00\x1e\xcb", + b"\x83\x10\x00x\xa4\xe9\xb9\x16\xa6\xca\xbeb\xa8\x88\xc0^\xa8\xb5\xbbo\xa8\x9d\xbb\x97\xa5\xe5\xc12\xa8\x1b\xbb\xe1\xa8\x7f\xbcG\xa7\xc2\xba\xea\xa9s\xc1|\xa72\xc0\xf0\xa9\x05\xbfJ\xa8r\xc0m\xa7\xca\xbd\xf5\xa9d\xbe\xda\xab^\xbc\xf9\xaa\x0e\xbd\x14\xa9\x82\xbe\x9a\xaa\x81\xc0\xb1\xa8\x8f\xbb\xe1\xa8\xe6\xbf\xae\xa8\xdf\xc0\x86\xa9\xa2\xc1 \xa8\xa3\xbet\xab@\xc12\xaaL\xc1'\xa7\xfe\xbc\x9e\xa9\x92\xbe\xd0\xa7\x82\xbd\xcd\xa9;\xbe\xc5\xae", + b"\x83\x10\x00\x06\x00\x006\xde\x00\x1ec", + b"\x83\x10\x00x\xa7\x9f\xb9<\xa8\x08\xbc\xa2\xa8\x00\xbe\xfa\xa8\xa5\xbe\xa6\xa8\xcf\xb7\x9a\xa8\x9c\xbb\xac\xa8\x8f\xbb\xd8\xa8\x91\xc0\xb4\xa7\x0f\xbe\x8f\xa8\xdf\xc0\xfc\xa7o\xbb\xce\xa8\x08\xbe\x0c\xa9 \xbey\xa9\x7f\xc0]\xa7\x0c\xbc\xad\xa9\xeb\xbfr\xa7y\xba5\xa9\x92\xbcW\xa8Q\xbe\xf3\xab\xce\xc3\x96\xa9\xc1\xbe\x1a\xaa\x91\xbcG\xa8\x82\xbdR\xaa\xf6\xc0\xf1\xa7\xdc\xbd\xd3\xa8\x17\xbe\xf5\xac\xa7\xbc\xfa\xaa(\xb9\xfe\xaal\xc12\xaa\x17\xc1Y\xe4", + b"\x83\x10\x00\x06\x00\x008\xdf\x00\x1el", + b"\x83\x10\x00x\xa3\x7f\xc10\xa8W\xcb\xb5\xa6\r\xc4\x1b\xa8T\xc6}\xa6\xeb\xc7\xb3\xa9\x0f\xccF\xa7\xec\xc6\x90\xa8\x02\xc9\xc2\xa9=\xc5\xa7\xaa\xa4\xc7\x9b\xa9d\xc1\x17\xa9\x91\xc8}\xa8\xfd\xc9\xed\xa7\xaf\xc6\xc1\xa8\x95\xcb\xf4\xa8E\xc7O\xa9\x8a\xc5:\xa8\xc6\xcc\xa6\xab \xca,\xa9C\xc5\x96\xa8\xef\xc7\xf0\xa9\x0c\xca\x9d\xa83\xc4Z\xa9V\xc2\x00\xa9\x86\xc6O\xaa&\xc94\xa9\x9f\xcb\x98\xa9j\xc6;\xa8\xb6\xc7\xe6\xa9\x81\xc99+", + ] + fluop = FluorescenceProcessor() + proc = fluop.process(fluo) + res = [ + [ + 47948.62782562279, + 47952.18275926122, + 46652.99592416698, + 45646.78062435356, + 47650.381479622505, + 47338.837493347826, + 47808.66757608314, + 49076.496551486445, + 47532.80952833949, + 48178.720264450945, + 47931.65850286039, + 50658.76437083986, + ] + ] + assert proc == res + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py b/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py new file mode 100644 index 0000000000..3200e3f4a0 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py @@ -0,0 +1,286 @@ +import asyncio +import contextlib +import logging +from enum import Enum + +import usb.core +import usb.util + +from .spark_packet_parser import parse_single_spark_packet + +# Tecan Spark +VENDOR_ID = 0x0C47 + + +class SparkDevice(Enum): + FLUORESCENCE = 0x8027 + ABSORPTION = 0x8026 + LUMINESCENCE = 0x8022 + PLATE_TRANSPORT = 0x8028 + + +class SparkEndpoint(Enum): + BULK_IN = 0x82 + BULK_IN1 = 0x81 + BULK_OUT = 0x01 + INTERRUPT_IN = 0x83 + + +class SparkReaderAsync: + def __init__(self, vid=VENDOR_ID): + self.vid = vid + self.devices = {} + self.endpoints = {} + self.seq_num = 0 + self.lock = asyncio.Lock() + self.msgs = [] + + def connect(self): + found_devices = list(usb.core.find(find_all=True, idVendor=self.vid)) + if not found_devices: + raise ValueError(f"No devices found for VID={hex(self.vid)}") + + logging.info(f"Found {len(found_devices)} devices with VID={hex(self.vid)}.") + + for d in found_devices: + device_type = None + try: + device_type = SparkDevice(d.idProduct) + except ValueError: + logging.warning(f"Unknown device type with PID={hex(d.idProduct)}") + continue + + if device_type in self.devices: + logging.warning(f"Duplicate device type {device_type} found. Skipping.") + continue + + try: + if d.is_kernel_driver_active(0): + logging.debug(f"Detaching kernel driver from {device_type.name} interface 0") + d.detach_kernel_driver(0) + except usb.core.USBError as e: + logging.error(f"Error detaching kernel driver from {device_type.name}: {e}") + + try: + d.set_configuration() + cfg = d.get_active_configuration() + intf = cfg[(0, 0)] + + ep_bulk_out = usb.util.find_descriptor(intf, bEndpointAddress=SparkEndpoint.BULK_OUT.value) + ep_bulk_in = usb.util.find_descriptor(intf, bEndpointAddress=SparkEndpoint.BULK_IN.value) + ep_bulk_in1 = usb.util.find_descriptor(intf, bEndpointAddress=SparkEndpoint.BULK_IN1.value) + ep_interrupt_in = usb.util.find_descriptor( + intf, bEndpointAddress=SparkEndpoint.INTERRUPT_IN.value + ) + self.devices[device_type] = d + self.endpoints[device_type] = { + SparkEndpoint.BULK_OUT: ep_bulk_out, + SparkEndpoint.BULK_IN: ep_bulk_in, + SparkEndpoint.BULK_IN1: ep_bulk_in1, + SparkEndpoint.INTERRUPT_IN: ep_interrupt_in, + } + # Note: calling set_configuration(0) twice before switching to configuration 1 + # is intentional. Some Tecan Spark devices require a double reset to config 0 + # to reliably accept configuration 1 after startup. + d.set_configuration(0) + d.set_configuration(0) + d.set_configuration(1) + + logging.info( + f"Successfully configured {device_type.name} (PID: {hex(d.idProduct)} SN: {d.serial_number})" + ) + + except usb.core.USBError as e: + logging.error(f"USBError configuring {device_type.name}: {e}") + except Exception as e: + logging.error(f"Error configuring {device_type.name}: {e}") + + if not self.devices: + raise ValueError(f"Failed to connect to any known Spark devices for VID={hex(self.vid)}") + + logging.info(f"Successfully connected to {len(self.devices)} devices.") + + def _calculate_checksum(self, data): + checksum = 0 + for byte in data: + checksum ^= byte + return checksum + + async def _usb_read(self, endpoint, timeout, count=None): + if count is None: + count = endpoint.wMaxPacketSize + return await asyncio.to_thread(endpoint.read, count, timeout=timeout) + + async def _usb_write(self, endpoint, data): + return await asyncio.to_thread(endpoint.write, data) + + async def send_command(self, command_str, device_type=SparkDevice.PLATE_TRANSPORT): + if device_type not in self.devices: + logging.error(f"Device type {device_type} not connected.") + return False + + endpoints = self.endpoints[device_type] + ep_bulk_out = endpoints[SparkEndpoint.BULK_OUT] + + async with self.lock: + logging.debug(f"Sending to {device_type.name}: {command_str}") + payload = command_str.encode("ascii") + payload_len = len(payload) + + header = bytes([0x01, self.seq_num, 0x00, payload_len]) + message = header + payload + bytes([self._calculate_checksum(header + payload)]) + self.seq_num = (self.seq_num + 1) % 256 + + try: + await self._usb_write(ep_bulk_out, message) + logging.debug(f"Sent message to {device_type.name}: {message.hex()}") + return True + except usb.core.USBError as e: + logging.error(f"USB error sending command to {device_type.name}: {e}") + return False + except Exception as e: + logging.error(f"Error sending command to {device_type.name}: {e}", exc_info=True) + return False + + def init_read(self, in_endpoint, count=512, read_timeout=2000): + logging.debug(f"Initiating read task on {hex(in_endpoint.bEndpointAddress)}...") + self.cur_in_endpoint = in_endpoint + return asyncio.create_task(self._usb_read(in_endpoint, read_timeout, count)) + + async def get_response(self, read_task, timeout=2000, attempts=10000): + try: + data = await read_task + + if data is None: + logging.warning("Read task returned None") + return None + + data_bytes = bytes(data) + logging.debug(f"Read task completed ({len(data_bytes)} bytes): {data_bytes.hex()}") + parsed = parse_single_spark_packet(data_bytes) + + if parsed.get("type") == "RespMessage": + self.msgs.append(parsed["payload"]) + elif parsed.get("type") == "RespError": + raise Exception(parsed) + + while parsed.get("type") != "RespReady" and attempts > 0: + attempts -= 1 + try: + await asyncio.sleep(0.01) + logging.debug(f"Still busy, retrying... attempts left: {attempts}") + resp = await self._usb_read(self.cur_in_endpoint, 20, 512) + if resp: + logging.debug(f"Read task completed ({len(resp)} bytes): {bytes(resp).hex()}") + parsed = parse_single_spark_packet(bytes(resp)) + logging.debug(f"Parsed: {parsed}") + if parsed.get("type") == "RespMessage": + self.msgs.append(parsed["payload"]) + elif parsed.get("type") == "RespError": + raise Exception(parsed) + except usb.core.USBError as e: + if e.errno == 110: # Timeout + await asyncio.sleep(0.1) + else: + logging.error(f"USB error in get_response: {e}") + + return parsed + + except asyncio.CancelledError: + logging.warning("Read task was cancelled") + return None + except Exception as e: + logging.error(f"Error in get_response: {e}", exc_info=True) + return None + + def clear_messages(self): + """Clear the list of recorded RespMessage payloads.""" + self.msgs = [] + + @contextlib.asynccontextmanager + async def reading( + self, + device_type=SparkDevice.PLATE_TRANSPORT, + endpoint=SparkEndpoint.INTERRUPT_IN, + count=512, + read_timeout=2000, + ): + if device_type not in self.devices: + raise ValueError(f"Device type {device_type} not connected.") + + ep = self.endpoints[device_type].get(endpoint) + if not ep: + raise ValueError(f"Endpoint {endpoint} not found for {device_type.name}.") + + read_task = self.init_read(ep, count, read_timeout) + await asyncio.sleep(0.01) # Short delay to ensure the read task starts + + response_task = asyncio.create_task(self.get_response(read_task)) + + try: + yield response_task + finally: + logging.debug( + f"Context manager exiting, awaiting read task for {device_type.name} {endpoint.name}" + ) + if not response_task.done(): + await response_task + + try: + response = response_task.result() + logging.debug(f"Response from context manager read: {response}") + except Exception as e: + logging.debug(f"Response task exception: {e}") + + async def start_background_read( + self, device_type, endpoint=SparkEndpoint.INTERRUPT_IN, read_timeout=100 + ): + if device_type not in self.devices: + logging.error(f"Device type {device_type} not connected.") + return None, None, None + + ep = self.endpoints[device_type].get(endpoint) + if not ep: + logging.error(f"Endpoint {endpoint} not found for {device_type.name}.") + return None, None, None + + stop_event = asyncio.Event() + results = [] + + async def background_reader(): + logging.info( + f"Starting background reader for {device_type.name} {endpoint.name} (0x{ep.bEndpointAddress:02x})" + ) + while not stop_event.is_set(): + await asyncio.sleep(0.2) # Avoid tight loop + try: + data = await self._usb_read(ep, read_timeout, 1024) + if data: + results.append(bytes(data)) + logging.debug(f"Background read {len(data)} bytes: {bytes(data).hex()}") + except usb.core.USBError as e: + if e.errno == 110: # Timeout + pass + else: + logging.error(f"USB error in background reader: {e}") + await asyncio.sleep(0.1) # Avoid tight loop on other errors + except asyncio.CancelledError: + logging.info("Background reader cancelled.") + break + except Exception as e: + logging.error(f"Error in background reader: {e}", exc_info=True) + await asyncio.sleep(0.1) + logging.info(f"Stopping background reader for {device_type.name} {endpoint.name}") + + task = asyncio.create_task(background_reader()) + return task, stop_event, results + + async def close(self): + for device_type, device in self.devices.items(): + try: + await asyncio.to_thread(usb.util.dispose_resources, device) + logging.info(f"{device_type.name} resources released.") + except Exception as e: + logging.error(f"Error closing {device_type.name}: {e}") + self.devices = {} + self.endpoints = {} diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py b/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py new file mode 100644 index 0000000000..7c3c248149 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py @@ -0,0 +1,278 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +# Import the module under test +from pylabrobot.plate_reading.tecan.spark20m.spark_reader_async import ( + SparkDevice, + SparkEndpoint, + SparkReaderAsync, +) + + +class TestSparkReaderAsync(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + # Patch usb.core and usb.util inside the module + self.usb_core_patcher = patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.usb.core" + ) + self.usb_util_patcher = patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.usb.util" + ) + + self.mock_usb_core = self.usb_core_patcher.start() + self.mock_usb_util = self.usb_util_patcher.start() + + # Setup MockUSBError + class MockUSBError(Exception): + def __init__(self, errno=None, *args): + super().__init__(*args) + self.errno = errno + + self.mock_usb_core.USBError = MockUSBError + + self.reader = SparkReaderAsync() + + async def asyncTearDown(self): + self.usb_core_patcher.stop() + self.usb_util_patcher.stop() + + async def test_connect_success(self): + # Mock device + mock_dev = MagicMock() + mock_dev.idProduct = SparkDevice.PLATE_TRANSPORT.value + mock_dev.is_kernel_driver_active.return_value = True + mock_dev.serial_number = "TEST_SERIAL" + + # Mock configuration and endpoints + mock_cfg = {(0, 0): MagicMock()} + mock_dev.get_active_configuration.return_value = mock_cfg + + self.mock_usb_core.find.return_value = iter([mock_dev]) + self.mock_usb_util.find_descriptor.side_effect = ["ep_out", "ep_in", "ep_in1", "ep_int"] + + self.reader.connect() + + self.assertIn(SparkDevice.PLATE_TRANSPORT, self.reader.devices) + self.assertEqual(self.reader.devices[SparkDevice.PLATE_TRANSPORT], mock_dev) + mock_dev.detach_kernel_driver.assert_called_with(0) + # Check set_configuration calls: 0, 0, 1 + # mock_dev.set_configuration.assert_has_calls([call(0), call(0), call(1)]) # Code calls it multiple times? + # Code: + # d.set_configuration() (no args?) -> Wait, existing code: d.set_configuration(), then d.set_configuration(0), d.set_configuration(0), d.set_configuration(1) + # Let's just check it was called. + self.assertTrue(mock_dev.set_configuration.called) + + async def test_connect_no_devices(self): + self.mock_usb_core.find.return_value = iter([]) + with self.assertRaisesRegex(ValueError, "No devices found"): + self.reader.connect() + + async def test_connect_unknown_device(self): + mock_dev = MagicMock() + mock_dev.idProduct = 0xFFFF # Unknown PID + self.mock_usb_core.find.return_value = iter([mock_dev]) + + with self.assertRaisesRegex(ValueError, "Failed to connect to any known Spark devices"): + self.reader.connect() + + async def test_send_command(self): + # Setup connected device + mock_dev = MagicMock() + mock_ep_out = MagicMock() + mock_ep_out.write = MagicMock() # sync write + + self.reader.devices[SparkDevice.PLATE_TRANSPORT] = mock_dev + self.reader.endpoints[SparkDevice.PLATE_TRANSPORT] = {SparkEndpoint.BULK_OUT: mock_ep_out} + + # Mock calculate_checksum to return a predictable value + with patch.object(self.reader, "_calculate_checksum", return_value=0x99): + success = await self.reader.send_command("CMD") + + self.assertTrue(success) + # Expected message: header + payload + checksum + # Header: 0x01, seq=0, 0x00, len=3 + # Payload: b"CMD" + # Checksum: 0x99 + expected_msg = bytes([0x01, 0x00, 0x00, 0x03]) + b"CMD" + bytes([0x99]) + mock_ep_out.write.assert_called_with(expected_msg) + self.assertEqual(self.reader.seq_num, 1) + + async def test_send_command_device_not_connected(self): + success = await self.reader.send_command("CMD", device_type=SparkDevice.ABSORPTION) + self.assertFalse(success) + + async def test_usb_read(self): + mock_ep = MagicMock() + mock_ep.read = MagicMock(return_value=b"data") + + data = await self.reader._usb_read(mock_ep, timeout=100) + + self.assertEqual(data, b"data") + mock_ep.read.assert_called_with(mock_ep.wMaxPacketSize, timeout=100) + + async def test_get_response_success(self): + # Mock parse_single_spark_packet + with patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + ) as mock_parse: + mock_parse.return_value = {"type": "RespReady", "payload": {"status": "OK"}} + + read_task: asyncio.Future = asyncio.Future() + read_task.set_result(b"response_bytes") + + parsed = await self.reader.get_response(read_task) + + self.assertEqual(parsed, {"type": "RespReady", "payload": {"status": "OK"}}) + + async def test_get_response_busy_then_ready(self): + # This tests the retry loop + mock_ep = MagicMock() + self.reader.cur_in_endpoint = mock_ep + + # Mock _usb_read to return data on retry + with patch.object(self.reader, "_usb_read", new_callable=AsyncMock) as mock_read, patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + ) as mock_parse: + # Sequence of parse results: + # 1. First read (passed as task): RespMessage (busy/intermediate) + # 2. Retry read 1: RespReady + mock_parse.side_effect = [ + {"type": "RespMessage", "payload": "msg1"}, + {"type": "RespReady", "payload": "done"}, + ] + + mock_read.return_value = b"retry_data" + + read_task: asyncio.Future = asyncio.Future() + read_task.set_result(b"initial_data") + + parsed = await self.reader.get_response(read_task, attempts=5) + + self.assertEqual(parsed, {"type": "RespReady", "payload": "done"}) + self.assertIn("msg1", self.reader.msgs) + # Should have called _usb_read once for the retry + mock_read.assert_called_once() + + async def test_reading_context_manager(self): + mock_dev = MagicMock() + mock_ep = MagicMock() + self.reader.devices[SparkDevice.PLATE_TRANSPORT] = mock_dev + self.reader.endpoints[SparkDevice.PLATE_TRANSPORT] = {SparkEndpoint.INTERRUPT_IN: mock_ep} + + with patch.object(self.reader, "init_read") as mock_init_read, patch.object( + self.reader, "get_response", new_callable=AsyncMock + ) as mock_get_resp: + read_task_mock: asyncio.Future = asyncio.Future() + mock_init_read.return_value = read_task_mock + + mock_get_resp.return_value = {"status": "ok"} + + async with self.reader.reading(SparkDevice.PLATE_TRANSPORT): + pass + + mock_init_read.assert_called_with(mock_ep, 512, 2000) + mock_get_resp.assert_awaited() + + async def test_start_background_read(self): + mock_dev = MagicMock() + mock_ep = MagicMock() + # Ensure wMaxPacketSize is set + mock_ep.wMaxPacketSize = 64 + # Ensure bEndpointAddress is set for logging + mock_ep.bEndpointAddress = 0x81 + + self.reader.devices[SparkDevice.PLATE_TRANSPORT] = mock_dev + self.reader.endpoints[SparkDevice.PLATE_TRANSPORT] = {SparkEndpoint.INTERRUPT_IN: mock_ep} + + # Mock _usb_read + with patch.object(self.reader, "_usb_read", new_callable=AsyncMock) as mock_read: + # Return some data then block or raise CancelledError + # Note: The background reader catches CancelledError and exits. + # We need to simulate: + # 1. Read successful data + # 2. Read successful data + # 3. Raise CancelledError (simulating task cancellation or just ending the loop via exception injection) + + async def side_effect(*args, **kwargs): + if mock_read.call_count == 1: + return b"data1" + elif mock_read.call_count == 2: + return b"data2" + else: + # Stall until cancelled + await asyncio.sleep(10) + return None + + mock_read.side_effect = side_effect + + task, stop_event, results = await self.reader.start_background_read( + SparkDevice.PLATE_TRANSPORT + ) + + self.assertIsNotNone(task) + + # Let it run to collect data + await asyncio.sleep(0.5) # Wait for 2 reads (0.2 sleep in loop) + + stop_event.set() + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + self.assertIn(b"data1", results) + self.assertIn(b"data2", results) + + async def test_close(self): + mock_dev = MagicMock() + self.reader.devices[SparkDevice.PLATE_TRANSPORT] = mock_dev + + await self.reader.close() + + self.assertEqual(self.reader.devices, {}) + # Ensure dispose_resources called on the mocked module + self.mock_usb_util.dispose_resources.assert_called_with(mock_dev) + + async def test_connect_usb_error(self): + # Device 1: Fails + mock_dev1 = MagicMock() + mock_dev1.idProduct = SparkDevice.PLATE_TRANSPORT.value + mock_dev1.is_kernel_driver_active.return_value = False + # Raise USBError on set_configuration + mock_dev1.set_configuration.side_effect = self.mock_usb_core.USBError(None, "Error") + + # Device 2: Succeeds + mock_dev2 = MagicMock() + mock_dev2.idProduct = SparkDevice.ABSORPTION.value + mock_dev2.is_kernel_driver_active.return_value = False + mock_dev2.get_active_configuration.return_value = {(0, 0): MagicMock()} + + self.mock_usb_core.find.return_value = iter([mock_dev1, mock_dev2]) + # Simplify descriptor finding for all calls + self.mock_usb_util.find_descriptor.return_value = MagicMock() + + self.reader.connect() + + # Device 1 should not be in devices + self.assertNotIn(SparkDevice.PLATE_TRANSPORT, self.reader.devices) + # Device 2 should be in devices + self.assertIn(SparkDevice.ABSORPTION, self.reader.devices) + + async def test_get_response_error(self): + with patch( + "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + ) as mock_parse: + mock_parse.return_value = {"type": "RespError", "payload": {"error": "BadCommand"}} + + read_task: asyncio.Future = asyncio.Future() + read_task.set_result(b"error_bytes") + + # get_response catches exceptions and logs them, returning None + result = await self.reader.get_response(read_task) + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_reading/utils.py b/pylabrobot/plate_reading/utils.py new file mode 100644 index 0000000000..f7e4afef43 --- /dev/null +++ b/pylabrobot/plate_reading/utils.py @@ -0,0 +1,59 @@ +from typing import Iterable, List, Tuple + +from pylabrobot.resources import Plate, Well + + +def _non_overlapping_rectangles( + points: Iterable[Tuple[int, int]], +) -> List[Tuple[int, int, int, int]]: + """Find non-overlapping rectangles that cover all given points. + + Example: + >>> points = [ + >>> (1, 1), + >>> (2, 2), (2, 3), (2, 4), + >>> (3, 2), (3, 3), (3, 4), + >>> (4, 2), (4, 3), (4, 4), (4, 5), + >>> (5, 2), (5, 3), (5, 4), (5, 5), + >>> (6, 2), (6, 3), (6, 4), (6, 5), + >>> (7, 2), (7, 3), (7, 4), + >>> ] + >>> non_overlapping_rectangles(points) + [ + (1, 1, 1, 1), + (2, 2, 7, 4), + (4, 5, 6, 5), + ] + """ + + pts = set(points) + rects = [] + + while pts: + # start a rectangle from one arbitrary point + r0, c0 = min(pts) + # expand right + c1 = c0 + while (r0, c1 + 1) in pts: + c1 += 1 + # expand downward as long as entire row segment is filled + r1 = r0 + while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)): + r1 += 1 + + rects.append((r0, c0, r1, c1)) + # remove covered points + for r in range(r0, r1 + 1): + for c in range(c0, c1 + 1): + pts.discard((r, c)) + + rects.sort() + return rects + + +def _get_min_max_row_col_tuples(wells: List[Well], plate: Plate) -> List[Tuple[int, int, int, int]]: + """Get a list of (min_row, min_col, max_row, max_col) tuples for the given wells.""" + plates = set(well.parent for well in wells) + if len(plates) != 1 or plates.pop() != plate: + raise ValueError("All wells must be in the specified plate") + return _non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells)