From 8c6c81d3ee44471d799a7f176818b4061561e31e Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Thu, 9 Oct 2025 21:37:55 -0700 Subject: [PATCH 01/18] added atc support for proflex backend --- pylabrobot/thermocycling/proflex.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/pylabrobot/thermocycling/proflex.py b/pylabrobot/thermocycling/proflex.py index 5767142b7ee..1c7ab37e3e7 100644 --- a/pylabrobot/thermocycling/proflex.py +++ b/pylabrobot/thermocycling/proflex.py @@ -401,8 +401,11 @@ async def _load_num_blocks_and_type(self): elif self.bid == "13": self._num_blocks = 3 self.num_temp_zones = 2 + elif self.bid == "31": + self._num_blocks = 1 + self.num_temp_zones = 1 else: - raise NotImplementedError("Only BID 12 and 13 are supported") + raise NotImplementedError("Only BID 31, 12 and 13 are supported") async def is_block_running(self, block_id: int) -> bool: run_name = await self.get_run_name(block_id=block_id) @@ -554,6 +557,20 @@ async def buzzer_off(self): if self._parse_scpi_response(res)["status"] != "OK": raise ValueError("Failed to turn off buzzer") + async def close_lid(self): + if self.bid != '31': + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res=await self.send_command({"cmd": f"lidclose"}, response_timeout=20, read_once=False) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to close lid") + + async def open_lid(self): + if self.bid != '31': + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res=await self.send_command({"cmd": f"lidopen"}, response_timeout=20, read_once=False) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to open lid") + async def send_morse_code(self, morse_code: str): short_beep_duration = 0.1 long_beep_duration = short_beep_duration * 3 @@ -852,11 +869,7 @@ async def setup( await self.set_block_idle_temp(temp=block_idle_temp, block_id=block_index) await self.set_cover_idle_temp(temp=cover_idle_temp, block_id=block_index) - async def open_lid(self): - raise NotImplementedError("Open lid command is not implemented for Proflex thermocycler") - async def close_lid(self): - raise NotImplementedError("Close lid command is not implemented for Proflex thermocycler") async def deactivate_lid(self, block_id: Optional[int] = None): assert block_id is not None, "block_id must be specified" From e9a4c3b3eb29a72e5d9ee7fbc5e03ec3549a4f61 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 11 Oct 2025 11:22:51 -0700 Subject: [PATCH 02/18] abstract --- pylabrobot/thermocycling/__init__.py | 15 +-------- .../thermocycling/thermo_fisher/__init__.py | 2 ++ pylabrobot/thermocycling/thermo_fisher/atc.py | 19 +++++++++++ .../thermocycling/thermo_fisher/proflex.py | 11 +++++++ .../{ => thermo_fisher}/proflex_tests.py | 4 +-- .../thermo_fisher_thermocycler.py} | 32 +++++-------------- 6 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 pylabrobot/thermocycling/thermo_fisher/__init__.py create mode 100644 pylabrobot/thermocycling/thermo_fisher/atc.py create mode 100644 pylabrobot/thermocycling/thermo_fisher/proflex.py rename pylabrobot/thermocycling/{ => thermo_fisher}/proflex_tests.py (98%) rename pylabrobot/thermocycling/{proflex.py => thermo_fisher/thermo_fisher_thermocycler.py} (97%) diff --git a/pylabrobot/thermocycling/__init__.py b/pylabrobot/thermocycling/__init__.py index 5499cfc1f28..084f2aed8d7 100644 --- a/pylabrobot/thermocycling/__init__.py +++ b/pylabrobot/thermocycling/__init__.py @@ -1,20 +1,7 @@ -"""This module contains the thermocycling related classes and functions.""" - from .backend import ThermocyclerBackend from .chatterbox import ThermocyclerChatterboxBackend from .opentrons import OpentronsThermocyclerModuleV1, OpentronsThermocyclerModuleV2 from .opentrons_backend import OpentronsThermocyclerBackend -from .proflex import ProflexBackend from .standard import Step +from .thermo_fisher import * from .thermocycler import Thermocycler - -__all__ = [ - "ThermocyclerBackend", - "ThermocyclerChatterboxBackend", - "Thermocycler", - "ProflexBackend", - "Step", - "OpentronsThermocyclerBackend", - "OpentronsThermocyclerModuleV1", - "OpentronsThermocyclerModuleV2", -] diff --git a/pylabrobot/thermocycling/thermo_fisher/__init__.py b/pylabrobot/thermocycling/thermo_fisher/__init__.py new file mode 100644 index 00000000000..43240dfc8f1 --- /dev/null +++ b/pylabrobot/thermocycling/thermo_fisher/__init__.py @@ -0,0 +1,2 @@ +from .atc import ATCBackend +from .proflex import ProflexBackend diff --git a/pylabrobot/thermocycling/thermo_fisher/atc.py b/pylabrobot/thermocycling/thermo_fisher/atc.py new file mode 100644 index 00000000000..5e5fd89faa5 --- /dev/null +++ b/pylabrobot/thermocycling/thermo_fisher/atc.py @@ -0,0 +1,19 @@ +from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( + ThermoFisherThermocyclerBackend, +) + + +class ATCBackend(ThermoFisherThermocyclerBackend): + async def close_lid(self): + if self.bid != "31": + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res = await self.send_command({"cmd": f"lidclose"}, response_timeout=20, read_once=False) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to close lid") + + async def open_lid(self): + if self.bid != "31": + raise NotImplementedError("Lid control is only available for BID 31 (ATC)") + res = await self.send_command({"cmd": f"lidopen"}, response_timeout=20, read_once=False) + if self._parse_scpi_response(res)["status"] != "OK": + raise ValueError("Failed to open lid") diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex.py b/pylabrobot/thermocycling/thermo_fisher/proflex.py new file mode 100644 index 00000000000..da6c387e239 --- /dev/null +++ b/pylabrobot/thermocycling/thermo_fisher/proflex.py @@ -0,0 +1,11 @@ +from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( + ThermoFisherThermocyclerBackend, +) + + +class ProflexBackend(ThermoFisherThermocyclerBackend): + async def open_lid(self): + raise NotImplementedError("Open lid command is not implemented for Proflex thermocycler") + + async def close_lid(self): + raise NotImplementedError("Close lid command is not implemented for Proflex thermocycler") diff --git a/pylabrobot/thermocycling/proflex_tests.py b/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py similarity index 98% rename from pylabrobot/thermocycling/proflex_tests.py rename to pylabrobot/thermocycling/thermo_fisher/proflex_tests.py index dbc4d703760..8242624ee21 100644 --- a/pylabrobot/thermocycling/proflex_tests.py +++ b/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py @@ -2,8 +2,8 @@ import unittest import unittest.mock -from pylabrobot.thermocycling.proflex import ProflexBackend from pylabrobot.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.thermocycling.thermo_fisher.proflex import ProflexBackend class TestProflexBackend(unittest.IsolatedAsyncioTestCase): @@ -168,7 +168,7 @@ async def test_run_protocol(self): -cover= 105 -mode= Fast -coverEnabled= On - -notes= + -notes= """ ).strip() diff --git a/pylabrobot/thermocycling/proflex.py b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py similarity index 97% rename from pylabrobot/thermocycling/proflex.py rename to pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 1c7ab37e3e7..32b2c1ed510 100644 --- a/pylabrobot/thermocycling/proflex.py +++ b/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -4,16 +4,16 @@ import logging import re import xml.etree.ElementTree as ET +from abc import ABCMeta from base64 import b64decode from dataclasses import dataclass from typing import Any, Dict, List, Optional, cast from xml.dom import minidom from pylabrobot.io import Socket +from pylabrobot.thermocycling.backend import ThermocyclerBackend from pylabrobot.thermocycling.standard import LidStatus, Protocol, Stage, Step -from .backend import ThermocyclerBackend - def _generate_run_info_files( protocol: Protocol, @@ -203,7 +203,7 @@ def stage_to_scpi(stage: Stage, stage_index: int, stage_name_prefix: str) -> dic return data -class ProflexBackend(ThermocyclerBackend): +class ThermoFisherThermocyclerBackend(ThermocyclerBackend, metaclass=ABCMeta): """Backend for Proflex thermocycler.""" def __init__(self, ip: str, port: int = 7000, shared_secret: bytes = b"f4ct0rymt55"): @@ -557,20 +557,6 @@ async def buzzer_off(self): if self._parse_scpi_response(res)["status"] != "OK": raise ValueError("Failed to turn off buzzer") - async def close_lid(self): - if self.bid != '31': - raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res=await self.send_command({"cmd": f"lidclose"}, response_timeout=20, read_once=False) - if self._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to close lid") - - async def open_lid(self): - if self.bid != '31': - raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res=await self.send_command({"cmd": f"lidopen"}, response_timeout=20, read_once=False) - if self._parse_scpi_response(res)["status"] != "OK": - raise ValueError("Failed to open lid") - async def send_morse_code(self, morse_code: str): short_beep_duration = 0.1 long_beep_duration = short_beep_duration * 3 @@ -795,7 +781,7 @@ async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress" run_name = await self.get_run_name(block_id=block_id) if not progress: self.logger.info("Protocol completed") - return ProflexBackend.RunProgress( + return ThermoFisherThermocyclerBackend.RunProgress( running=False, stage="completed", elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name), @@ -805,7 +791,7 @@ async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress" if progress["RunTitle"] == "-": await self._read_response(timeout=5) self.logger.info("Protocol completed") - return ProflexBackend.RunProgress( + return ThermoFisherThermocyclerBackend.RunProgress( running=False, stage="completed", elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name), @@ -814,7 +800,7 @@ async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress" if progress["Stage"] == "POSTRun": self.logger.info("Protocol in POSTRun") - return ProflexBackend.RunProgress( + return ThermoFisherThermocyclerBackend.RunProgress( running=True, stage="POSTRun", elapsed_time=await self.get_elapsed_run_time_from_log(run_name=run_name), @@ -837,7 +823,7 @@ async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress" break await asyncio.sleep(5) self.logger.info("Infinite hold") - return ProflexBackend.RunProgress( + return ThermoFisherThermocyclerBackend.RunProgress( running=False, stage="infinite_hold", elapsed_time=time_elapsed, @@ -846,7 +832,7 @@ async def get_run_info(self, protocol: Protocol, block_id: int) -> "RunProgress" self.logger.info(f"Elapsed time: {time_elapsed}") self.logger.info(f"Remaining time: {remaining_time}") - return ProflexBackend.RunProgress( + return ThermoFisherThermocyclerBackend.RunProgress( running=True, stage=progress["Stage"], elapsed_time=time_elapsed, @@ -869,8 +855,6 @@ async def setup( await self.set_block_idle_temp(temp=block_idle_temp, block_id=block_index) await self.set_cover_idle_temp(temp=cover_idle_temp, block_id=block_index) - - async def deactivate_lid(self, block_id: Optional[int] = None): assert block_id is not None, "block_id must be specified" return await self.set_cover_idle_temp(temp=105, control_enabled=False, block_id=block_id) From 792bd0eb0b423d396b18f7c256975181312a4ac6 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 11 Oct 2025 11:32:09 -0700 Subject: [PATCH 03/18] x --- pylabrobot/thermocycling/thermo_fisher/atc.py | 4 ++-- pylabrobot/thermocycling/thermo_fisher/proflex_tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pylabrobot/thermocycling/thermo_fisher/atc.py b/pylabrobot/thermocycling/thermo_fisher/atc.py index 5e5fd89faa5..55342ebe347 100644 --- a/pylabrobot/thermocycling/thermo_fisher/atc.py +++ b/pylabrobot/thermocycling/thermo_fisher/atc.py @@ -7,13 +7,13 @@ class ATCBackend(ThermoFisherThermocyclerBackend): async def close_lid(self): if self.bid != "31": raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res = await self.send_command({"cmd": f"lidclose"}, response_timeout=20, read_once=False) + res = await self.send_command({"cmd": "lidclose"}, response_timeout=20, read_once=False) if self._parse_scpi_response(res)["status"] != "OK": raise ValueError("Failed to close lid") async def open_lid(self): if self.bid != "31": raise NotImplementedError("Lid control is only available for BID 31 (ATC)") - res = await self.send_command({"cmd": f"lidopen"}, response_timeout=20, read_once=False) + res = await self.send_command({"cmd": "lidopen"}, response_timeout=20, read_once=False) if self._parse_scpi_response(res)["status"] != "OK": raise ValueError("Failed to open lid") diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py b/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py index 8242624ee21..fbf2ab10fd3 100644 --- a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py +++ b/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py @@ -168,7 +168,7 @@ async def test_run_protocol(self): -cover= 105 -mode= Fast -coverEnabled= On - -notes= + -notes=\x20 """ ).strip() From 936959aa088b148c97dae292f0f511f24b1cd469 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Mon, 13 Oct 2025 14:57:26 -0700 Subject: [PATCH 04/18] molecular devices plate reader backend --- .../plate_reading/molecularDevices_backend.py | 675 ++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 pylabrobot/plate_reading/molecularDevices_backend.py diff --git a/pylabrobot/plate_reading/molecularDevices_backend.py b/pylabrobot/plate_reading/molecularDevices_backend.py new file mode 100644 index 00000000000..e3ee79f3735 --- /dev/null +++ b/pylabrobot/plate_reading/molecularDevices_backend.py @@ -0,0 +1,675 @@ +import asyncio +import logging +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Literal, Optional, Union, Tuple, Dict + +from pylabrobot.io.serial import Serial +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources.plate import Plate + +logger = logging.getLogger("pylabrobot") + +# This map is a direct translation of the `ConstructCommandList` method in MaxlineModel.cs +# It maps the base command string to the number of terminating characters (response fields) expected. +COMMAND_TERMINATORS: Dict[str, int] = { + "!AUTOFILTER": 1, + "!AUTOPMT": 1, + "!BAUD": 1, + "!CALIBRATE": 1, + "!CANCEL": 1, + "!CLEAR": 1, + "!CLOSE": 1, + "!CSPEED": 1, + "!REFERENCE": 1, + "!EMFILTER": 1, + "!EMWAVELENGTH": 1, + "!ERROR": 2, + "!EXWAVELENGTH": 1, + "!FPW": 1, + "!INIT": 1, + "!MODE": 1, + "!NVRAM": 1, + "!OPEN": 1, + "!ORDER": 1, + "!AIR_CAL": 1, + "!PMT": 1, + "!PMTCAL": 1, + "!QUEUE": 2, + "!READ": 1, + "!TOP": 1, + "!READSTAGE": 2, + "!READTYPE": 2, + "!RESEND": 1, + "!RESET": 1, + "!SHAKE": 1, + "!SPEED": 2, + "!STATUS": 2, + "!STRIP": 1, + "!TAG": 1, + "!TEMP": 2, + "!TRANSFER": 2, + "!USER_NUMBER": 2, + "!XPOS": 1, + "!YPOS": 1, + "!WAVELENGTH": 1, + "!WELLSCANMODE": 2, + "!PATHCAL": 2, + "!COUNTTIME": 1, + "!COUNTTIMEDELAY": 1, +} + + +class MolecularDevicesError(Exception): + """Exceptions raised by a Molecular Devices plate reader.""" + + +MolecularDevicesResponse = List[str] + + +class ReadMode(Enum): + """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" + ABS = "ABS" + FLU = "FLU" + LUM = "LUM" + POLAR = "POLAR" + TIME = "TIME" + + +class ReadType(Enum): + """The type of read to perform (e.g., Endpoint, Kinetic).""" + ENDPOINT = "ENDPOINT" + KINETIC = "KINETIC" + SPECTRUM = "SPECTRUM" + WELL_SCAN = "WELLSCAN" + + +class ReadOrder(Enum): + """The order in which to read the plate wells.""" + COLUMN = "COLUMN" + WAVELENGTH = "WAVELENGTH" + + +class Calibrate(Enum): + """The calibration mode for the read.""" + ON = "ON" + ONCE = "ONCE" + OFF = "OFF" + + +class CarriageSpeed(Enum): + """The speed of the plate carriage.""" + NORMAL = "8" + SLOW = "1" + + +class PmtGain(Enum): + """The photomultiplier tube gain setting.""" + AUTO = "ON" + HIGH = "HIGH" + MEDIUM = "MED" + LOW = "LOW" + + +@dataclass +class ShakeSettings: + """Settings for shaking the plate during a read.""" + before_read: bool = False + before_read_duration: int = 0 + between_reads: bool = False + between_reads_duration: int = 0 + + +@dataclass +class KineticSettings: + """Settings for kinetic reads.""" + interval: int + num_readings: int + + +@dataclass +class SpectrumSettings: + """Settings for spectrum reads.""" + start_wavelength: int + step: int + num_steps: int + excitation_emission_type: Optional[Literal["EXSPECTRUM", "EMSPECTRUM"]] = None + + +@dataclass +class MolecularDevicesSettings: + """A comprehensive, internal container for all plate reader settings.""" + plate: Plate = field(repr=False) + read_mode: ReadMode + read_type: ReadType + read_order: ReadOrder + calibrate: Calibrate + shake_settings: Optional[ShakeSettings] + carriage_speed: CarriageSpeed + speed_read: bool + kinetic_settings: Optional[KineticSettings] + spectrum_settings: Optional[SpectrumSettings] + wavelengths: List[Union[int, Tuple[int, bool]]] = field(default_factory=list) + excitation_wavelengths: List[int] = field(default_factory=list) + emission_wavelengths: List[int] = field(default_factory=list) + cutoff_filters: List[int] = field(default_factory=list) + path_check: bool = False + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 1 + cuvette: bool = False + + +class MolecularDevicesBackend(PlateReaderBackend): + """Backend for Molecular Devices Spectralmax plate readers. + + This backend is a faithful implementation based on the "Maxline" command set + as detailed in the reverse-engineered C# source code. + """ + + def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + self.port = port + self.io = Serial(self.port, baudrate=9600, timeout=1) + self.res_term_char = res_term_char + + async def setup(self) -> None: + await self.io.setup() + await self.send_command("!") + + async def stop(self) -> None: + await self.io.stop() + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def send_command(self, command: str, timeout: int = 60, num_res_fields=None) -> MolecularDevicesResponse: + """Send a command and receive the response, automatically determining the number of + response fields. + """ + base_command = command.split(" ")[0] + if num_res_fields is None: + num_res_fields = COMMAND_TERMINATORS.get(base_command, 1) + else: + num_res_fields = max(1, num_res_fields) + + await self.io.write(command.encode() + b"\r") + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response += await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError(f"Timeout waiting for response to command: {command}") + if raw_response.count(self.res_term_char) >= num_res_fields: + break + logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) + response = raw_response.decode("utf-8").strip().split(self.res_term_char.decode()) + response = [r.strip() for r in response if r.strip() != ''] + self._parse_basic_errors(response, command) + return response + + async def _send_commands(self, commands: List[Optional[str]]) -> None: + """Send a sequence of commands to the plate reader.""" + for command in commands: + if command: + await self.send_command(command) + + def _parse_basic_errors(self, response: List[str], command: str) -> None: + if not response or 'OK' not in response[0]: + raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") + elif 'warning' in response[0].lower(): + logger.warning("Warning for command '%s': %s", command, response) + + async def open(self) -> None: + await self.send_command("!OPEN") + + async def close(self, plate: Optional[Plate] = None) -> None: + await self.send_command("!CLOSE") + + async def get_status(self) -> List[str]: + res = await self.send_command("!STATUS") + return res[1].split() + + async def read_error_log(self) -> str: + res = await self.send_command("!ERROR") + return res[1] + + async def clear_error_log(self) -> None: + await self.send_command("!CLEAR ERROR") + + async def get_temperature(self) -> Tuple[float, float]: + res = await self.send_command("!TEMP") + parts = res[1].split() + return (float(parts[1]), float(parts[0])) # current, set_point + + async def set_temperature(self, temperature: float) -> None: + if not (0 <= temperature <= 45): + raise ValueError("Temperature must be between 0 and 45°C.") + await self.send_command(f"!TEMP {temperature}") + + async def get_firmware_version(self) -> str: + await self.io.write(b"!OPTIONS\r") + raw_response = b"" + timeout_time = time.time() + 10 + while True: + raw_response += await self.io.read() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError("Timeout waiting for firmware version.") + if raw_response.count(self.res_term_char) >= 1: # !OPTIONS is not in the map, assume 1 + break + response_str = raw_response.decode("utf-8") + lines = response_str.strip().split('\n') + return lines[5].strip() if len(lines) >= 6 else lines[-1].strip().replace(">", "").strip() + + async def start_shake(self) -> None: + await self.send_command("!SHAKE NOW") + + async def stop_shake(self) -> None: + await self.send_command("!SHAKE STOP") + + async def _read_now(self) -> None: + await self.send_command("!READ") + + async def _transfer_data(self) -> str: + res = await self.send_command("!TRANSFER") + return res[1] + + def _parse_data(self, data_str: str) -> List[List[float]]: + data = [] + rows = data_str.strip().split('\r') + for row in rows: + if not row: + continue + try: + values_str = row.strip().split('\t') + if len(values_str) == 1: + values_str = row.strip().split() + values = [float(v) for v in values_str] + data.append(values) + except (ValueError, IndexError): + logger.warning("Could not parse row: %s", row) + return data + + def _get_clear_command(self) -> str: + return "!CLEAR DATA" + + def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: + cmd = f"!MODE {settings.read_type.value}" + if settings.read_type == ReadType.KINETIC and settings.kinetic_settings: + ks = settings.kinetic_settings + cmd += f" {ks.interval} {ks.num_readings}" + elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: + ss = settings.spectrum_settings + scan_type = ss.excitation_emission_type or "SPECTRUM" + cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" + return cmd + + def _get_wavelength_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode == ReadMode.ABS: + wl_parts = [] + for wl in settings.wavelengths: + wl_parts.append(f"F{wl[0]}" if isinstance(wl, tuple) and wl[1] else str(wl)) + wl_str = " ".join(wl_parts) + if settings.path_check: + wl_str += " 900 998" + return [f"!WAVELENGTH {wl_str}"] + if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): + ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) + em_wl_str = " ".join(map(str, settings.emission_wavelengths)) + return [f"!EXWAVELENGTH {ex_wl_str}", f"!EMWAVELENGTH {em_wl_str}"] + if settings.read_mode == ReadMode.LUM: + wl_str = " ".join(map(str, settings.emission_wavelengths)) + return [f"!EMWAVELENGTH {wl_str}"] + return [] + + def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> List[str]: + plate = settings.plate + 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.") + + loc_A1 = plate.get_item("A1").location + loc_A2 = plate.get_item("A2").location + loc_B1 = plate.get_item("B1").location + dx = loc_A2.x - loc_A1.x + dy = loc_A1.y - loc_B1.y + + x_pos_cmd = f"!XPOS {loc_A1.x:.3f} {dx:.3f} {num_cols}" + y_pos_cmd = f"!YPOS {size_y-loc_A1.y:.3f} {dy:.3f} {num_rows}" + return [x_pos_cmd, y_pos_cmd] + + def _get_strip_command(self, settings: MolecularDevicesSettings) -> str: + return f"!STRIP 1 {settings.plate.num_items_x}" + + def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if not settings.shake_settings: + return ["!SHAKE OFF"] + ss = settings.shake_settings + shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" + before_duration = ss.before_read_duration if ss.before_read else 0 + ki = settings.kinetic_settings.interval if settings.kinetic_settings else 0 + if ss.between_reads and ki > 0: + between_duration = ss.between_reads_duration + wait_duration = ki - between_duration + else: + between_duration = 0 + wait_duration = 0 + return [ + f"!SHAKE {shake_mode}", + f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" + ] + + def _get_carriage_speed_command(self, settings: MolecularDevicesSettings) -> str: + return f"!CSPEED {settings.carriage_speed.value}" + + def _get_read_stage_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + stage = "BOT" if settings.read_from_bottom else "TOP" + return f"!READSTAGE {stage}" + return None + + def _get_flashes_per_well_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + return f"!FPW {settings.flashes_per_well}" + return None + + def _get_pmt_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + return [] + gain = settings.pmt_gain + if gain == PmtGain.AUTO: + return ["!AUTOPMT ON"] + gain_val = gain.value if isinstance(gain, PmtGain) else gain + return ["!AUTOPMT OFF", f"!PMT {gain_val}"] + + def _get_filter_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) and settings.cutoff_filters: + cf_str = " ".join(map(str, settings.cutoff_filters)) + return ["!AUTOFILTER OFF", f"!EMFILTER {cf_str}"] + return [] + + def _get_calibrate_command(self, settings: MolecularDevicesSettings) -> str: + if settings.read_mode == ReadMode.ABS: + return f"!CALIBRATE {settings.calibrate.value}" + return f"!PMTCAL {settings.calibrate.value}" + + def _get_order_command(self, settings: MolecularDevicesSettings) -> str: + return f"!ORDER {settings.read_order.value}" + + def _get_speed_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode == ReadMode.ABS: + mode = "ON" if settings.speed_read else "OFF" + return f"!SPEED {mode}" + return None + + def _get_integration_time_commands( + self, + settings: MolecularDevicesSettings, + delay_time: int, + integration_time: int + ) -> List[str]: + if settings.read_mode == ReadMode.TIME: + return [ + f"!COUNTTIMEDELAY {delay_time}", + f"!COUNTTIME {integration_time * 0.001}" + ] + return [] + + async def _wait_for_idle(self, timeout: int = 120): + """Wait for the plate reader to become idle.""" + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise TimeoutError("Timeout waiting for plate reader to become idle.") + status = await self.get_status() + if status and status[1] == "IDLE": + break + await asyncio.sleep(1) + + async def read_absorbance( + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False + ) -> List[List[float]]: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.ABS, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, speed_read=speed_read, path_check=path_check, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + wavelengths=wavelengths, cuvette=cuvette + ) + commands = [self._get_clear_command()] + if not cuvette: + # commands.extend(self._get_plate_position_commands(settings)) + commands.append(self._get_strip_command(settings)) + commands.append(self._get_carriage_speed_command(settings)) + commands.extend(self._get_shake_commands(settings)) + commands.extend(self._get_wavelength_commands(settings)) + commands.append(self._get_calibrate_command(settings)) + commands.append(self._get_mode_command(settings)) + commands.append(self._get_order_command(settings)) + commands.append(self._get_speed_command(settings)) + commands.append(f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}") + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + data_str = await self._transfer_data() + return self._parse_data(data_str) + + async def read_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False + ) -> List[List[float]]: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.FLU, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False + ) + commands = [self._get_clear_command()] + # commands.append(self._get_read_stage_command(settings)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.append(self._get_strip_command(settings)) + commands.append(self._get_carriage_speed_command(settings)) + commands.extend(self._get_shake_commands(settings)) + commands.append(self._get_flashes_per_well_command(settings)) + commands.extend(self._get_pmt_commands(settings)) + commands.extend(self._get_wavelength_commands(settings)) + commands.extend(self._get_filter_commands(settings)) + commands.append(self._get_calibrate_command(settings)) + commands.append(self._get_mode_command(settings)) + commands.append(self._get_order_command(settings)) + + + await self._send_commands(commands) + if cuvette: + await self.send_command("!READTYPE FLUCUV",num_res_fields=2) + else: + await self.send_command("!READTYPE FLU",num_res_fields=1) + await self._read_now() + await self._wait_for_idle() + data_str = await self._transfer_data() + return self._parse_data(data_str) + + async def read_luminescence( + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False + ) -> List[List[float]]: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.LUM, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, cuvette=cuvette, speed_read=False + ) + commands = [self._get_clear_command()] + commands.append(self._get_read_stage_command(settings)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.append(self._get_strip_command(settings)) + commands.append(self._get_carriage_speed_command(settings)) + commands.extend(self._get_shake_commands(settings)) + commands.append(self._get_flashes_per_well_command(settings)) + commands.extend(self._get_pmt_commands(settings)) + commands.extend(self._get_wavelength_commands(settings)) + commands.append(self._get_calibrate_command(settings)) + commands.append(self._get_mode_command(settings)) + commands.append(self._get_order_command(settings)) + + + await self._send_commands(commands) + if cuvette: + await self.send_command("!READTYPE LUMCUV",num_res_fields=2) + else: + await self.send_command("!READTYPE LUM",num_res_fields=1) + await self._read_now() + await self._wait_for_idle() + data_str = await self._transfer_data() + return self._parse_data(data_str) + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False + ) -> List[List[float]]: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.POLAR, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False + ) + commands = [self._get_clear_command()] + # commands.append(self._get_read_stage_command(settings)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.append(self._get_strip_command(settings)) + commands.append(self._get_carriage_speed_command(settings)) + commands.extend(self._get_shake_commands(settings)) + commands.append(self._get_flashes_per_well_command(settings)) + commands.extend(self._get_pmt_commands(settings)) + commands.extend(self._get_wavelength_commands(settings)) + commands.extend(self._get_filter_commands(settings)) + commands.append(self._get_calibrate_command(settings)) + commands.append(self._get_mode_command(settings)) + commands.append(self._get_order_command(settings)) + commands.append("!READTYPE POLAR") + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + data_str = await self._transfer_data() + return self._parse_data(data_str) + + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False + ) -> List[List[float]]: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.TIME, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False + ) + commands = [self._get_clear_command()] + commands.append("!READTYPE TIME 0 250") + commands.extend(self._get_integration_time_commands(settings, delay_time, integration_time)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.append(self._get_strip_command(settings)) + commands.append(self._get_carriage_speed_command(settings)) + commands.extend(self._get_shake_commands(settings)) + commands.append(self._get_flashes_per_well_command(settings)) + commands.extend(self._get_pmt_commands(settings)) + commands.extend(self._get_wavelength_commands(settings)) + commands.extend(self._get_filter_commands(settings)) + commands.append(self._get_calibrate_command(settings)) + commands.append(self._get_mode_command(settings)) + commands.append(self._get_order_command(settings)) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + data_str = await self._transfer_data() + return self._parse_data(data_str) \ No newline at end of file From f130dbe06160f4b0aeb987654c27f4d660840c8b Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Mon, 13 Oct 2025 16:49:16 -0700 Subject: [PATCH 05/18] refactor, add test --- .../plate_reading/molecularDevices_backend.py | 198 +++++---- .../molecularDevices_backend_tests.py | 380 ++++++++++++++++++ 2 files changed, 503 insertions(+), 75 deletions(-) create mode 100644 pylabrobot/plate_reading/molecularDevices_backend_tests.py diff --git a/pylabrobot/plate_reading/molecularDevices_backend.py b/pylabrobot/plate_reading/molecularDevices_backend.py index e3ee79f3735..d97eb921142 100644 --- a/pylabrobot/plate_reading/molecularDevices_backend.py +++ b/pylabrobot/plate_reading/molecularDevices_backend.py @@ -209,11 +209,19 @@ async def send_command(self, command: str, timeout: int = 60, num_res_fields=Non self._parse_basic_errors(response, command) return response - async def _send_commands(self, commands: List[Optional[str]]) -> None: + async def _send_commands( + self, + commands: List[Union[Optional[str], Tuple[str, int]]] + ) -> None: """Send a sequence of commands to the plate reader.""" - for command in commands: - if command: - await self.send_command(command) + for command_info in commands: + if not command_info: + continue + if isinstance(command_info, tuple): + command, num_res_fields = command_info + await self.send_command(command, num_res_fields=num_res_fields) + else: + await self.send_command(command_info) def _parse_basic_errors(self, response: List[str], command: str) -> None: if not response or 'OK' not in response[0]: @@ -329,15 +337,16 @@ def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> Li 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) + 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 dx = loc_A2.x - loc_A1.x dy = loc_A1.y - loc_B1.y - x_pos_cmd = f"!XPOS {loc_A1.x:.3f} {dx:.3f} {num_cols}" - y_pos_cmd = f"!YPOS {size_y-loc_A1.y:.3f} {dy:.3f} {num_rows}" + x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" + y_pos_cmd = f"!YPOS {size_y-top_left_well_center.y:.3f} {dy:.3f} {num_rows}" return [x_pos_cmd, y_pos_cmd] def _get_strip_command(self, settings: MolecularDevicesSettings) -> str: @@ -404,6 +413,29 @@ def _get_speed_command(self, settings: MolecularDevicesSettings) -> Optional[str return f"!SPEED {mode}" return None + def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: + """Get the READTYPE command and the expected number of response fields.""" + cuvette = settings.cuvette + num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) + + if settings.read_mode == ReadMode.ABS: + cmd = f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}" + elif settings.read_mode == ReadMode.FLU: + cmd = f"!READTYPE FLU{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.LUM: + cmd = f"!READTYPE LUM{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.POLAR: + cmd = "!READTYPE POLAR" + elif settings.read_mode == ReadMode.TIME: + cmd = "!READTYPE TIME 0 250" + num_res_fields = 1 + else: + raise ValueError(f"Unsupported read mode: {settings.read_mode}") + + return (cmd, num_res_fields) + def _get_integration_time_commands( self, settings: MolecularDevicesSettings, @@ -450,18 +482,22 @@ async def read_absorbance( kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, wavelengths=wavelengths, cuvette=cuvette ) - commands = [self._get_clear_command()] + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] if not cuvette: # commands.extend(self._get_plate_position_commands(settings)) - commands.append(self._get_strip_command(settings)) - commands.append(self._get_carriage_speed_command(settings)) - commands.extend(self._get_shake_commands(settings)) - commands.extend(self._get_wavelength_commands(settings)) - commands.append(self._get_calibrate_command(settings)) - commands.append(self._get_mode_command(settings)) - commands.append(self._get_order_command(settings)) - commands.append(self._get_speed_command(settings)) - commands.append(f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}") + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + *self._get_wavelength_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_speed_command(settings), + self._get_readtype_command(settings) + ]) await self._send_commands(commands) await self._read_now() @@ -497,27 +533,27 @@ async def read_fluorescence( emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, cuvette=cuvette,speed_read=False ) - commands = [self._get_clear_command()] + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] # commands.append(self._get_read_stage_command(settings)) if not cuvette: commands.extend(self._get_plate_position_commands(settings)) - commands.append(self._get_strip_command(settings)) - commands.append(self._get_carriage_speed_command(settings)) - commands.extend(self._get_shake_commands(settings)) - commands.append(self._get_flashes_per_well_command(settings)) - commands.extend(self._get_pmt_commands(settings)) - commands.extend(self._get_wavelength_commands(settings)) - commands.extend(self._get_filter_commands(settings)) - commands.append(self._get_calibrate_command(settings)) - commands.append(self._get_mode_command(settings)) - commands.append(self._get_order_command(settings)) - + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_readtype_command(settings) + ]) await self._send_commands(commands) - if cuvette: - await self.send_command("!READTYPE FLUCUV",num_res_fields=2) - else: - await self.send_command("!READTYPE FLU",num_res_fields=1) await self._read_now() await self._wait_for_idle() data_str = await self._transfer_data() @@ -547,26 +583,28 @@ async def read_luminescence( kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, emission_wavelengths=emission_wavelengths, cuvette=cuvette, speed_read=False ) - commands = [self._get_clear_command()] - commands.append(self._get_read_stage_command(settings)) + commands: List[Union[Optional[str], Tuple[str, int]]] = [ + self._get_clear_command(), + self._get_read_stage_command(settings) + ] if not cuvette: commands.extend(self._get_plate_position_commands(settings)) - commands.append(self._get_strip_command(settings)) - commands.append(self._get_carriage_speed_command(settings)) - commands.extend(self._get_shake_commands(settings)) - commands.append(self._get_flashes_per_well_command(settings)) - commands.extend(self._get_pmt_commands(settings)) - commands.extend(self._get_wavelength_commands(settings)) - commands.append(self._get_calibrate_command(settings)) - commands.append(self._get_mode_command(settings)) - commands.append(self._get_order_command(settings)) - + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_readtype_command(settings) + ]) await self._send_commands(commands) - if cuvette: - await self.send_command("!READTYPE LUMCUV",num_res_fields=2) - else: - await self.send_command("!READTYPE LUM",num_res_fields=1) await self._read_now() await self._wait_for_idle() data_str = await self._transfer_data() @@ -600,21 +638,25 @@ async def read_fluorescence_polarization( emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, cuvette=cuvette,speed_read=False ) - commands = [self._get_clear_command()] + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] # commands.append(self._get_read_stage_command(settings)) if not cuvette: commands.extend(self._get_plate_position_commands(settings)) - commands.append(self._get_strip_command(settings)) - commands.append(self._get_carriage_speed_command(settings)) - commands.extend(self._get_shake_commands(settings)) - commands.append(self._get_flashes_per_well_command(settings)) - commands.extend(self._get_pmt_commands(settings)) - commands.extend(self._get_wavelength_commands(settings)) - commands.extend(self._get_filter_commands(settings)) - commands.append(self._get_calibrate_command(settings)) - commands.append(self._get_mode_command(settings)) - commands.append(self._get_order_command(settings)) - commands.append("!READTYPE POLAR") + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_readtype_command(settings) + ]) await self._send_commands(commands) await self._read_now() @@ -652,24 +694,30 @@ async def read_time_resolved_fluorescence( emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, cuvette=cuvette,speed_read=False ) - commands = [self._get_clear_command()] - commands.append("!READTYPE TIME 0 250") - commands.extend(self._get_integration_time_commands(settings, delay_time, integration_time)) + commands: List[Union[Optional[str], Tuple[str, int]]] = [ + self._get_clear_command(), + self._get_readtype_command(settings), + *self._get_integration_time_commands(settings, delay_time, integration_time) + ] if not cuvette: commands.extend(self._get_plate_position_commands(settings)) - commands.append(self._get_strip_command(settings)) - commands.append(self._get_carriage_speed_command(settings)) - commands.extend(self._get_shake_commands(settings)) - commands.append(self._get_flashes_per_well_command(settings)) - commands.extend(self._get_pmt_commands(settings)) - commands.extend(self._get_wavelength_commands(settings)) - commands.extend(self._get_filter_commands(settings)) - commands.append(self._get_calibrate_command(settings)) - commands.append(self._get_mode_command(settings)) - commands.append(self._get_order_command(settings)) + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings) + ]) await self._send_commands(commands) await self._read_now() await self._wait_for_idle() data_str = await self._transfer_data() - return self._parse_data(data_str) \ No newline at end of file + return self._parse_data(data_str) diff --git a/pylabrobot/plate_reading/molecularDevices_backend_tests.py b/pylabrobot/plate_reading/molecularDevices_backend_tests.py new file mode 100644 index 00000000000..46ee3d0ed66 --- /dev/null +++ b/pylabrobot/plate_reading/molecularDevices_backend_tests.py @@ -0,0 +1,380 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + +from pylabrobot.plate_reading.molecularDevices_backend import ( + MolecularDevicesBackend, + ReadMode, + ReadType, + ReadOrder, + Calibrate, + ShakeSettings, + CarriageSpeed, + PmtGain, + KineticSettings, + SpectrumSettings, + MolecularDevicesSettings, +) + +class TestMolecularDevicesBackend(unittest.TestCase): + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="COM1") + self.backend.io = self.mock_serial + + def test_setup_stop(self): + asyncio.run(self.backend.setup()) + self.mock_serial.setup.assert_called_once() + asyncio.run(self.backend.stop()) + self.mock_serial.stop.assert_called_once() + + def test_get_clear_command(self): + self.assertEqual(self.backend._get_clear_command(), "!CLEAR DATA") + + def test_get_mode_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE ENDPOINT") + + settings.read_type = ReadType.KINETIC + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE KINETIC 10 5") + + settings.read_type = ReadType.SPECTRUM + settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM SPECTRUM 200 10 50") + + settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" + self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM EXSPECTRUM 200 10 50") + + def test_get_wavelength_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600"]) + + settings.path_check = True + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"]) + + settings.read_mode = ReadMode.FLU + settings.excitation_wavelengths = [485] + settings.emission_wavelengths = [520] + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"]) + + settings.read_mode = ReadMode.LUM + settings.emission_wavelengths = [590] + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EMWAVELENGTH 590"]) + + def test_get_plate_position_commands(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + cmds = self.backend._get_plate_position_commands(settings) + self.assertEqual(len(cmds), 2) + self.assertEqual(cmds[0], "!XPOS 13.380 9.000 12") + self.assertEqual(cmds[1], "!YPOS 12.240 9.000 8") + + def test_get_strip_command(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_strip_command(settings), "!STRIP 1 12") + + def test_get_shake_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE OFF"]) + + settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 5 0 0 0 0"]) + + settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 0 10 7 3 0"]) + + def test_get_carriage_speed_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 8") + settings.carriage_speed = CarriageSpeed.SLOW + self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 1") + + def test_get_read_stage_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE TOP") + settings.read_from_bottom = True + self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE BOT") + settings.read_mode = ReadMode.ABS + self.assertIsNone(self.backend._get_read_stage_command(settings)) + + def test_get_flashes_per_well_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_flashes_per_well_command(settings), "!FPW 10") + settings.read_mode = ReadMode.ABS + self.assertIsNone(self.backend._get_flashes_per_well_command(settings)) + + def test_get_pmt_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + pmt_gain=PmtGain.AUTO, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT ON"]) + settings.pmt_gain = PmtGain.HIGH + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT HIGH"]) + settings.pmt_gain = 9 + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT 9"]) + settings.read_mode = ReadMode.ABS + self.assertEqual(self.backend._get_pmt_commands(settings), []) + + def test_get_filter_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + cutoff_filters=[515, 530], + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 515 530"]) + settings.cutoff_filters = [] + self.assertEqual(self.backend._get_filter_commands(settings), []) + settings.read_mode = ReadMode.ABS + settings.cutoff_filters = [515, 530] + self.assertEqual(self.backend._get_filter_commands(settings), []) + + def test_get_calibrate_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_calibrate_command(settings), "!CALIBRATE ON") + settings.read_mode = ReadMode.FLU + self.assertEqual(self.backend._get_calibrate_command(settings), "!PMTCAL ON") + + def test_get_order_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_order_command(settings), "!ORDER COLUMN") + settings.read_order = ReadOrder.WAVELENGTH + self.assertEqual(self.backend._get_order_command(settings), "!ORDER WAVELENGTH") + + def test_get_speed_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_speed_command(settings), "!SPEED ON") + settings.speed_read = False + self.assertEqual(self.backend._get_speed_command(settings), "!SPEED OFF") + settings.read_mode = ReadMode.FLU + self.assertIsNone(self.backend._get_speed_command(settings)) + + def test_get_integration_time_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.TIME, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), + ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"]) + settings.read_mode = ReadMode.ABS + self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), []) + + def test_parse_data(self): + data_str = "1.0\t2.0\t3.0\r\n4.0\t5.0\t6.0\r\n" + self.assertEqual(self.backend._parse_data(data_str), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + data_str_space = "1.0 2.0 3.0\r\n4.0 5.0 6.0\r\n" + self.assertEqual(self.backend._parse_data(data_str_space), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_absorbance(plate, [500])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!WAVELENGTH 500", commands) + self.assertIn("!CALIBRATE ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!SPEED OFF", commands) + self.assertIn(("!READTYPE ABSPLA", 2), commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn(("!READTYPE FLU", 1), commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 9d6bda8801ac8f8e730173b6e9e52c9abc587caf Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 15 Oct 2025 18:55:45 -0700 Subject: [PATCH 06/18] new features and error handling --- .../molecular_devices_backend.py | 1066 +++++++++++++++++ .../molecular_devices_backend_tests.py | 736 ++++++++++++ ...lar_devices_spectramax_384_plus_backend.py | 39 + ...molecular_devices_spectramax_m5_backend.py | 8 + 4 files changed, 1849 insertions(+) create mode 100644 pylabrobot/plate_reading/molecular_devices_backend.py create mode 100644 pylabrobot/plate_reading/molecular_devices_backend_tests.py create mode 100644 pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py create mode 100644 pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py new file mode 100644 index 00000000000..699a70df0b0 --- /dev/null +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -0,0 +1,1066 @@ +import asyncio +import logging +import re +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Literal, Optional, Union, Tuple, Dict +from abc import ABCMeta +from pylabrobot.io.serial import Serial +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources.plate import Plate + +logger = logging.getLogger("pylabrobot") + + +COMMAND_TERMINATORS: Dict[str, int] = { + "!AUTOFILTER": 1, + "!AUTOPMT": 1, + "!BAUD": 1, + "!CALIBRATE": 1, + "!CANCEL": 1, + "!CLEAR": 1, + "!CLOSE": 1, + "!CSPEED": 1, + "!REFERENCE": 1, + "!EMFILTER": 1, + "!EMWAVELENGTH": 1, + "!ERROR": 2, + "!EXWAVELENGTH": 1, + "!FPW": 1, + "!INIT": 1, + "!MODE": 1, + "!NVRAM": 1, + "!OPEN": 1, + "!ORDER": 1, + "OPTION": 2, + "!AIR_CAL": 1, + "!PMT": 1, + "!PMTCAL": 1, + "!QUEUE": 2, + "!READ": 1, + "!TOP": 1, + "!READSTAGE": 2, + "!READTYPE": 2, + "!RESEND": 1, + "!RESET": 1, + "!SHAKE": 1, + "!SPEED": 2, + "!STATUS": 2, + "!STRIP": 1, + "!TAG": 1, + "!TEMP": 2, + "!TRANSFER": 2, + "!USER_NUMBER": 2, + "!XPOS": 1, + "!YPOS": 1, + "!WAVELENGTH": 1, + "!WELLSCANMODE": 2, + "!PATHCAL": 2, + "!COUNTTIME": 1, + "!COUNTTIMEDELAY": 1, +} + + +class MolecularDevicesError(Exception): + """Exceptions raised by a Molecular Devices plate reader.""" + + +class MolecularDevicesUnrecognizedCommandError(MolecularDevicesError): + """Unrecognized command errors sent from the computer.""" + +class MolecularDevicesFirmwareError(MolecularDevicesError): + """Firmware errors.""" + +class MolecularDevicesHardwareError(MolecularDevicesError): + """Hardware errors.""" + +class MolecularDevicesMotionError(MolecularDevicesError): + """Motion errors.""" + +class MolecularDevicesNVRAMError(MolecularDevicesError): + """NVRAM errors.""" + + +ERROR_CODES: Dict[int, Tuple[str, type]] = { + 100: ("command not found", MolecularDevicesUnrecognizedCommandError), + 101: ("invalid argument", MolecularDevicesUnrecognizedCommandError), + 102: ("too many arguments", MolecularDevicesUnrecognizedCommandError), + 103: ("not enough arguments", MolecularDevicesUnrecognizedCommandError), + 104: ("input line too long", MolecularDevicesUnrecognizedCommandError), + 105: ("command invalid, system busy", MolecularDevicesUnrecognizedCommandError), + 106: ("command invalid, measurement in progress", MolecularDevicesUnrecognizedCommandError), + 107: ("no data to transfer", MolecularDevicesUnrecognizedCommandError), + 108: ("data buffer full", MolecularDevicesUnrecognizedCommandError), + 109: ("error buffer overflow", MolecularDevicesUnrecognizedCommandError), + 110: ("stray light cuvette, door open?", MolecularDevicesUnrecognizedCommandError), + 111: ("invalid read settings", MolecularDevicesUnrecognizedCommandError), + 200: ("assert failed", MolecularDevicesFirmwareError), + 201: ("bad error number", MolecularDevicesFirmwareError), + 202: ("receive queue overflow", MolecularDevicesFirmwareError), + 203: ("serial port parity error", MolecularDevicesFirmwareError), + 204: ("serial port overrun error", MolecularDevicesFirmwareError), + 205: ("serial port framing error", MolecularDevicesFirmwareError), + 206: ("cmd generated too much output", MolecularDevicesFirmwareError), + 207: ("fatal trap", MolecularDevicesFirmwareError), + 208: ("RTOS error", MolecularDevicesFirmwareError), + 209: ("stack overflow", MolecularDevicesFirmwareError), + 210: ("unknown interrupt", MolecularDevicesFirmwareError), + 300: ("thermistor faulty", MolecularDevicesHardwareError), + 301: ("safe temperature limit exceeded", MolecularDevicesHardwareError), + 302: ("low light", MolecularDevicesHardwareError), + 303: ("unable to cal dark current", MolecularDevicesHardwareError), + 304: ("signal level saturation", MolecularDevicesHardwareError), + 305: ("reference level saturation", MolecularDevicesHardwareError), + 306: ("plate air cal fail, low light", MolecularDevicesHardwareError), + 307: ("cuv air ref fail", MolecularDevicesHardwareError), + 308: ("stray light", MolecularDevicesHardwareError), + 312: ("gain calibration failed", MolecularDevicesHardwareError), + 313: ("reference gain check fail", MolecularDevicesHardwareError), + 314: ("low lamp level warning", MolecularDevicesHardwareError), + 315: ("can't find zero order", MolecularDevicesHardwareError), + 316: ("grating motor driver faulty", MolecularDevicesHardwareError), + 317: ("monitor ADC faulty", MolecularDevicesHardwareError), + 400: ("carriage motion error", MolecularDevicesMotionError), + 401: ("filter wheel error", MolecularDevicesMotionError), + 402: ("grating error", MolecularDevicesMotionError), + 403: ("stage error", MolecularDevicesMotionError), + 500: ("NVRAM CRC corrupt", MolecularDevicesNVRAMError), + 501: ("NVRAM Grating cal data bad", MolecularDevicesNVRAMError), + 502: ("NVRAM Cuvette air cal data error", MolecularDevicesNVRAMError), + 503: ("NVRAM Plate air cal data error", MolecularDevicesNVRAMError), + 504: ("NVRAM Carriage offset error", MolecularDevicesNVRAMError), + 505: ("NVRAM Stage offset error", MolecularDevicesNVRAMError), +} + + +MolecularDevicesResponse = List[str] + + +class ReadMode(Enum): + """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" + ABS = "ABS" + FLU = "FLU" + LUM = "LUM" + POLAR = "POLAR" + TIME = "TIME" + + +class ReadType(Enum): + """The type of read to perform (e.g., Endpoint, Kinetic).""" + ENDPOINT = "ENDPOINT" + KINETIC = "KINETIC" + SPECTRUM = "SPECTRUM" + WELL_SCAN = "WELLSCAN" + + +class ReadOrder(Enum): + """The order in which to read the plate wells.""" + COLUMN = "COLUMN" + WAVELENGTH = "WAVELENGTH" + + +class Calibrate(Enum): + """The calibration mode for the read.""" + ON = "ON" + ONCE = "ONCE" + OFF = "OFF" + + +class CarriageSpeed(Enum): + """The speed of the plate carriage.""" + NORMAL = "8" + SLOW = "1" + + +class PmtGain(Enum): + """The photomultiplier tube gain setting.""" + AUTO = "ON" + HIGH = "HIGH" + MEDIUM = "MED" + LOW = "LOW" + + +@dataclass +class ShakeSettings: + """Settings for shaking the plate during a read.""" + before_read: bool = False + before_read_duration: int = 0 + between_reads: bool = False + between_reads_duration: int = 0 + + +@dataclass +class KineticSettings: + """Settings for kinetic reads.""" + interval: int + num_readings: int + + +@dataclass +class SpectrumSettings: + """Settings for spectrum reads.""" + start_wavelength: int + step: int + num_steps: int + excitation_emission_type: Optional[Literal["EXSPECTRUM", "EMSPECTRUM"]] = None + + +@dataclass +class MolecularDevicesSettings: + """A comprehensive, internal container for all plate reader settings.""" + plate: Plate = field(repr=False) + read_mode: ReadMode + read_type: ReadType + read_order: ReadOrder + calibrate: Calibrate + shake_settings: Optional[ShakeSettings] + carriage_speed: CarriageSpeed + speed_read: bool + kinetic_settings: Optional[KineticSettings] + spectrum_settings: Optional[SpectrumSettings] + wavelengths: List[Union[int, Tuple[int, bool]]] = field(default_factory=list) + excitation_wavelengths: List[int] = field(default_factory=list) + emission_wavelengths: List[int] = field(default_factory=list) + cutoff_filters: List[int] = field(default_factory=list) + path_check: bool = False + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 1 + cuvette: bool = False + settling_time: int = 0 + is_settling_time_on: bool = False + + + +@dataclass +class MolecularDevicesData: + """Data from a Molecular Devices plate reader.""" + measurement_time: float + temperature: float + data: List[List[float]] + +@dataclass +class MolecularDevicesDataAbsorbance(MolecularDevicesData): + """Absorbance data from a Molecular Devices plate reader.""" + absorbance_wavelength: int + path_lengths: Optional[List[List[float]]] = None + +@dataclass +class MolecularDevicesDataFluorescence(MolecularDevicesData): + """Fluorescence data from a Molecular Devices plate reader.""" + excitation_wavelength: int + emission_wavelength: int + +@dataclass +class MolecularDevicesDataLuminescence(MolecularDevicesData): + """Luminescence data from a Molecular Devices plate reader.""" + emission_wavelength: int + + +@dataclass +class MolecularDevicesDataCollection: + """A collection of MolecularDevicesData objects from multiple reads.""" + container_type: str + reads: List["MolecularDevicesData"] + data_ordering: str + +@dataclass +class MolecularDevicesDataCollectionAbsorbance(MolecularDevicesDataCollection): + """A collection of MolecularDevicesDataAbsorbance objects from multiple reads.""" + reads: List["MolecularDevicesDataAbsorbance"] + all_absorbance_wavelengths: List[int] + +@dataclass +class MolecularDevicesDataCollectionFluorescence(MolecularDevicesDataCollection): + """A collection of MolecularDevicesDataFluorescence objects from multiple reads.""" + reads: List["MolecularDevicesDataFluorescence"] + all_excitation_wavelengths: List[int] + all_emission_wavelengths: List[int] + +@dataclass +class MolecularDevicesDataCollectionLuminescence(MolecularDevicesDataCollection): + """A collection of MolecularDevicesDataLuminescence objects from multiple reads.""" + reads: List["MolecularDevicesDataLuminescence"] + all_emission_wavelengths: List[int] + + +class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): + """Backend for Molecular Devices plate readers. + """ + + def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + self.port = port + self.io = Serial(self.port, baudrate=9600, timeout=0.2) + self.res_term_char = res_term_char + + async def setup(self) -> None: + await self.io.setup() + await self.send_command("!") + + async def stop(self) -> None: + await self.io.stop() + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def send_command(self, command: str, timeout: int = 60, num_res_fields=None) -> MolecularDevicesResponse: + """Send a command and receive the response, automatically determining the number of + response fields. + """ + base_command = command.split(" ")[0] + if num_res_fields is None: + num_res_fields = COMMAND_TERMINATORS.get(base_command, 1) + else: + num_res_fields = max(1, num_res_fields) + + await self.io.write(command.encode() + b"\r") + raw_response = b"" + timeout_time = time.time() + timeout + while True: + raw_response += await self.io.readline() + await asyncio.sleep(0.001) + if time.time() > timeout_time: + raise TimeoutError(f"Timeout waiting for response to command: {command}") + if raw_response.count(self.res_term_char) >= num_res_fields: + break + logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) + response = raw_response.decode("utf-8").strip().split(self.res_term_char.decode()) + response = [r.strip() for r in response if r.strip() != ''] + self._parse_basic_errors(response, command) + return response + + async def _send_commands( + self, + commands: List[Union[Optional[str], Tuple[str, int]]] + ) -> None: + """Send a sequence of commands to the plate reader.""" + for command_info in commands: + if not command_info: + continue + if isinstance(command_info, tuple): + command, num_res_fields = command_info + await self.send_command(command, num_res_fields=num_res_fields) + else: + await self.send_command(command_info) + + def _parse_basic_errors(self, response: List[str], command: str) -> None: + if not response: + raise MolecularDevicesError(f"Command '{command}' failed with empty response.") + + # Check for FAIL in the response + error_code_msg = response[0] if "FAIL" in response[0] else response[-1] + if "FAIL" in error_code_msg: + parts = error_code_msg.split("\t") + try: + error_code_str = parts[-1] + error_code = int(error_code_str.strip()) + if error_code in ERROR_CODES: + message, err_class = ERROR_CODES[error_code] + raise err_class(f"Command '{command}' failed with error {error_code}: {message}") + else: + raise MolecularDevicesError( + f"Command '{command}' failed with unknown error code: {error_code}" + ) + except (ValueError, IndexError): + raise MolecularDevicesError( + f"Command '{command}' failed with unparsable error: {response[0]}" + ) + + if 'OK' not in response[0]: + raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") + elif 'warning' in response[0].lower(): + logger.warning("Warning for command '%s': %s", command, response) + + async def open(self) -> None: + await self.send_command("!OPEN") + + async def close(self, plate: Optional[Plate] = None) -> None: + await self.send_command("!CLOSE") + + async def get_status(self) -> List[str]: + res = await self.send_command("!STATUS") + return res[1].split() + + async def read_error_log(self) -> str: + res = await self.send_command("!ERROR") + return res[1] + + async def clear_error_log(self) -> None: + await self.send_command("!CLEAR ERROR") + + async def get_temperature(self) -> Tuple[float, float]: + res = await self.send_command("!TEMP") + parts = res[1].split() + return (float(parts[1]), float(parts[0])) # current, set_point + + async def set_temperature(self, temperature: float) -> None: + if not (0 <= temperature <= 45): + raise ValueError("Temperature must be between 0 and 45°C.") + await self.send_command(f"!TEMP {temperature}") + + async def get_firmware_version(self) -> str: + await self.send_command("!OPTION") + async def start_shake(self) -> None: + await self.send_command("!SHAKE NOW") + + async def stop_shake(self) -> None: + await self.send_command("!SHAKE STOP") + + async def _read_now(self) -> None: + await self.send_command("!READ") + + async def _transfer_data( + self, + settings: MolecularDevicesSettings + ) -> "MolecularDevicesDataCollection": + """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each + reading and combine them into a single collection. + """ + + if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) \ + or (settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings): + num_readings = settings.kinetic_settings.num_readings if settings.kinetic_settings \ + else settings.spectrum_settings.num_steps + all_reads: List["MolecularDevicesData"] = [] + collection: Optional["MolecularDevicesDataCollection"] = None + + for _ in range(num_readings): + res = await self.send_command("!TRANSFER") + data_str = res[1] + parsed_data_collection = self._parse_data(data_str) + + if collection is None: + collection = parsed_data_collection + all_reads.extend(parsed_data_collection.reads) + + collection.reads = all_reads + return collection + else: + res = await self.send_command("!TRANSFER") + data_str = res[1] + return self._parse_data(data_str) + + def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": + lines = re.split(r'\r\n|\n', data_str.strip()) + lines = [l.strip() for l in lines if l.strip()] + + # 1. Parse header + header_parts = lines[0].split('\t') + measurement_time = float(header_parts[0]) + temperature = float(header_parts[1]) + container_type = header_parts[2] + + # 2. Parse wavelengths + absorbance_wavelengths = [] + excitation_wavelengths = [] + emission_wavelengths = [] + line_idx = 1 + while line_idx < len(lines): + line = lines[line_idx] + if line.startswith("L:") and line_idx == 1: + parts = line.split('\t') + for part in parts[1:]: + if part.strip(): + absorbance_wavelengths.append(int(part.strip())) + elif line.startswith("exL:"): + parts = line.split('\t') + for part in parts[1:]: + if part.strip() and part.strip().isdigit(): + excitation_wavelengths.append(int(part.strip())) + elif line.startswith("emL:"): + parts = line.split('\t') + for part in parts[1:]: + if part.strip(): + emission_wavelengths.append(int(part.strip())) + elif line.startswith("L:") and line_idx > 1: + # Data section started + break + line_idx += 1 + data_collection=[] + cur_read_wavelengths=[] + # 3. Parse data + data_columns = [] + # The data section starts at line_idx + for i in range(line_idx, len(lines)): + line = lines[i] + if line.startswith("L:"): + #start of a new data with different wavelength + cur_read_wavelengths.append(line.split('\t')[1:]) + if i > line_idx and data_columns: + data_collection.append(data_columns) + data_columns=[] + match = re.match(r"^\s*(\d+):\s*(.*)", line) + if match: + values_str = re.split(r'\s+', match.group(2).strip()) + values=[] + for v in values_str: + if v.strip().replace('.','',1).isdigit(): + values.append(float(v.strip())) + elif v.strip() == "#SAT": + values.append(float('inf')) + else: + values.append(float('nan')) + data_columns.append(values) + if data_columns: + data_collection.append(data_columns) + + # 4. Transpose data to be row-major + data_collection_transposed = [] + for data_columns in data_collection: + data_rows = [] + if data_columns: + num_rows = len(data_columns[0]) + num_cols = len(data_columns) + for i in range(num_rows): + row = [data_columns[j][i] for j in range(num_cols)] + data_rows.append(row) + data_collection_transposed.append(data_rows) + + if absorbance_wavelengths: + reads=[] + for i,data_rows in enumerate(data_collection_transposed): + wl = int(cur_read_wavelengths[i][0]) + reads.append(MolecularDevicesDataAbsorbance( + measurement_time=measurement_time, + temperature=temperature, + data=data_rows, + absorbance_wavelength=wl, + path_lengths=None + )) + return MolecularDevicesDataCollectionAbsorbance( + + container_type=container_type, + reads=reads, + all_absorbance_wavelengths=absorbance_wavelengths, + data_ordering="row-major" + ) + + elif excitation_wavelengths and emission_wavelengths: + reads=[] + for i,data_rows in enumerate(data_collection_transposed): + ex_wl = int(cur_read_wavelengths[i][0]) + em_wl = int(cur_read_wavelengths[i][1]) + reads.append(MolecularDevicesDataFluorescence( + measurement_time=measurement_time, + temperature=temperature, + data=data_rows, + excitation_wavelength=ex_wl, + emission_wavelength=em_wl + )) + return MolecularDevicesDataCollectionFluorescence( + container_type=container_type, + reads=reads, + all_excitation_wavelengths=excitation_wavelengths, + all_emission_wavelengths=emission_wavelengths, + data_ordering="row-major" + ) + elif emission_wavelengths: + reads=[] + for i,data_rows in enumerate(data_collection_transposed): + em_wl = int(cur_read_wavelengths[i][1]) + reads.append(MolecularDevicesDataLuminescence( + measurement_time=measurement_time, + temperature=temperature, + + data=data_rows, + emission_wavelength=em_wl + )) + return MolecularDevicesDataCollectionLuminescence( + container_type=container_type, + reads=reads, + all_emission_wavelengths=emission_wavelengths, + data_ordering="row-major" + ) + # Default to generic MolecularDevicesData if no specific wavelengths found + raise ValueError("Unable to determine data type from response.") + + def _get_clear_command(self) -> str: + return "!CLEAR DATA" + + def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: + cmd = f"!MODE {settings.read_type.value}" + if settings.read_type == ReadType.KINETIC and settings.kinetic_settings: + ks = settings.kinetic_settings + cmd += f" {ks.interval} {ks.num_readings}" + elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: + ss = settings.spectrum_settings + cmd= f"!MODE" + scan_type = ss.excitation_emission_type or "SPECTRUM" + cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" + return cmd + + def _get_wavelength_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode == ReadMode.ABS: + wl_parts = [] + for wl in settings.wavelengths: + wl_parts.append(f"F{wl[0]}" if isinstance(wl, tuple) and wl[1] else str(wl)) + wl_str = " ".join(wl_parts) + if settings.path_check: + wl_str += " 900 998" + return [f"!WAVELENGTH {wl_str}"] + if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): + ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) + em_wl_str = " ".join(map(str, settings.emission_wavelengths)) + return [f"!EXWAVELENGTH {ex_wl_str}", f"!EMWAVELENGTH {em_wl_str}"] + if settings.read_mode == ReadMode.LUM: + wl_str = " ".join(map(str, settings.emission_wavelengths)) + return [f"!EMWAVELENGTH {wl_str}"] + return [] + + def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> List[str]: + plate = settings.plate + 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) + 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 + dx = loc_A2.x - loc_A1.x + dy = loc_A1.y - loc_B1.y + + x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" + y_pos_cmd = f"!YPOS {size_y-top_left_well_center.y:.3f} {dy:.3f} {num_rows}" + return [x_pos_cmd, y_pos_cmd] + + def _get_strip_command(self, settings: MolecularDevicesSettings) -> str: + return f"!STRIP 1 {settings.plate.num_items_x}" + + def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if not settings.shake_settings: + return ["!SHAKE OFF"] + ss = settings.shake_settings + shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" + before_duration = ss.before_read_duration if ss.before_read else 0 + ki = settings.kinetic_settings.interval if settings.kinetic_settings else 0 + if ss.between_reads and ki > 0: + between_duration = ss.between_reads_duration + wait_duration = ki - between_duration + else: + between_duration = 0 + wait_duration = 0 + return [ + f"!SHAKE {shake_mode}", + f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" + ] + + def _get_carriage_speed_command(self, settings: MolecularDevicesSettings) -> str: + return f"!CSPEED {settings.carriage_speed.value}" + + def _get_read_stage_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + stage = "BOT" if settings.read_from_bottom else "TOP" + return f"!READSTAGE {stage}" + return None + + def _get_flashes_per_well_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + return f"!FPW {settings.flashes_per_well}" + return None + + def _get_pmt_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): + return [] + gain = settings.pmt_gain + if gain == PmtGain.AUTO: + return ["!AUTOPMT ON"] + gain_val = gain.value if isinstance(gain, PmtGain) else gain + return ["!AUTOPMT OFF", f"!PMT {gain_val}"] + + def _get_filter_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) and settings.cutoff_filters: + cf_str = " ".join(map(str, settings.cutoff_filters)) + return ["!AUTOFILTER OFF", f"!EMFILTER {cf_str}"] + return ["!AUTOFILTER ON"] + + def _get_calibrate_command(self, settings: MolecularDevicesSettings) -> str: + if settings.read_mode == ReadMode.ABS: + return f"!CALIBRATE {settings.calibrate.value}" + return f"!PMTCAL {settings.calibrate.value}" + + def _get_order_command(self, settings: MolecularDevicesSettings) -> str: + return f"!ORDER {settings.read_order.value}" + + def _get_speed_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + if settings.read_mode == ReadMode.ABS: + mode = "ON" if settings.speed_read else "OFF" + return f"!SPEED {mode}" + return None + + def _get_nvram_commands(self, settings: MolecularDevicesSettings) -> List[str]: + if settings.read_mode == ReadMode.POLAR: + command = "FPSETTLETIME" + value = settings.settling_time if settings.is_settling_time_on else 0 + else: + command = "CARCOL" + value = settings.settling_time if settings.is_settling_time_on else 100 + return [f"!NVRAM {command} {value}"] + + def _get_tag_command(self, settings: MolecularDevicesSettings) -> str: + if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: + return "!TAG ON" + return "!TAG OFF" + + def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: + """Get the READTYPE command and the expected number of response fields.""" + cuvette = settings.cuvette + num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) + + if settings.read_mode == ReadMode.ABS: + cmd = f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}" + elif settings.read_mode == ReadMode.FLU: + cmd = f"!READTYPE FLU{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.LUM: + cmd = f"!READTYPE LUM{'CUV' if cuvette else ''}" + num_res_fields = 2 if cuvette else 1 + elif settings.read_mode == ReadMode.POLAR: + cmd = "!READTYPE POLAR" + num_res_fields = 1 + elif settings.read_mode == ReadMode.TIME: + cmd = "!READTYPE TIME 0 250" + num_res_fields = 1 + else: + raise ValueError(f"Unsupported read mode: {settings.read_mode}") + + return (cmd, num_res_fields) + + def _get_integration_time_commands( + self, + settings: MolecularDevicesSettings, + delay_time: int, + integration_time: int + ) -> List[str]: + if settings.read_mode == ReadMode.TIME: + return [ + f"!COUNTTIMEDELAY {delay_time}", + f"!COUNTTIME {integration_time * 0.001}" + ] + return [] + + def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: + """Converts a wavelength to a cutoff filter index.""" + # This map is a direct translation of the `EmissionCutoff.CutoffFilter` in MaxlineModel.cs + # (min_wavelength, max_wavelength, cutoff_filter_index) + FILTERS = [ + (0, 322, 1), + (325, 415, 16), + (420, 435, 2), + (435, 455, 3), + (455, 475, 4), + (475, 495, 5), + (495, 515, 6), + (515, 530, 7), + (530, 550, 8), + (550, 570, 9), + (570, 590, 10), + (590, 610, 11), + (610, 630, 12), + (630, 665, 13), + (665, 695, 14), + (695, 900, 15), + ] + for min_wl, max_wl, cutoff_filter_index in FILTERS: + if min_wl <= wavelength < max_wl: + return cutoff_filter_index + raise ValueError(f"No cutoff filter found for wavelength {wavelength}") + + async def _wait_for_idle(self, timeout: int = 120): + """Wait for the plate reader to become idle.""" + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise TimeoutError("Timeout waiting for plate reader to become idle.") + status = await self.get_status() + if status and status[1] == "IDLE": + break + await asyncio.sleep(1) + + async def read_absorbance( + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False + ) -> MolecularDevicesDataCollection: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.ABS, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, speed_read=speed_read, path_check=path_check, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + wavelengths=wavelengths, cuvette=cuvette, + settling_time=settling_time, is_settling_time_on=is_settling_time_on + ) + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] + if not cuvette: + # commands.extend() + commands.extend([ + *self._get_plate_position_commands(settings), + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + *self._get_wavelength_commands(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_speed_command(settings), + self._get_tag_command(settings), + *self._get_nvram_commands(settings), + self._get_readtype_command(settings) + ]) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + return await self._transfer_data(settings) + + async def read_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False + ) -> MolecularDevicesDataCollection: + '''use _get_cutoff_filter_index_from_wavelength for cutoff_filters''' + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.FLU, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False, + settling_time=settling_time, is_settling_time_on=is_settling_time_on + ) + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] + # commands.append(self._get_read_stage_command(settings)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_read_stage_command(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_tag_command(settings), + *self._get_nvram_commands(settings), + self._get_readtype_command(settings) + ]) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + return await self._transfer_data(settings) + + async def read_luminescence( + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False + ) -> MolecularDevicesDataCollection: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.LUM, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, cuvette=cuvette, speed_read=False, + settling_time=settling_time, is_settling_time_on=is_settling_time_on + ) + commands: List[Union[Optional[str], Tuple[str, int]]] = [ + self._get_clear_command(), + self._get_read_stage_command(settings) + ] + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + self._get_read_stage_command(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_tag_command(settings), + *self._get_nvram_commands(settings), + self._get_readtype_command(settings) + ]) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + return await self._transfer_data(settings) + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False + ) -> MolecularDevicesDataCollection: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.POLAR, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False, + settling_time=settling_time, is_settling_time_on=is_settling_time_on + ) + commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] + # commands.append(self._get_read_stage_command(settings)) + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_read_stage_command(settings), + self._get_calibrate_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_tag_command(settings), + *self._get_nvram_commands(settings), + self._get_readtype_command(settings) + ]) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + return await self._transfer_data(settings) + + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False + ) -> MolecularDevicesDataCollection: + settings = MolecularDevicesSettings( + plate=plate, read_mode=ReadMode.TIME, read_type=read_type, + read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, + carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, + cuvette=cuvette,speed_read=False, + settling_time=settling_time, is_settling_time_on=is_settling_time_on + ) + commands: List[Union[Optional[str], Tuple[str, int]]] = [ + self._get_clear_command(), + self._get_readtype_command(settings), + *self._get_integration_time_commands(settings, delay_time, integration_time) + ] + if not cuvette: + commands.extend(self._get_plate_position_commands(settings)) + commands.extend([ + self._get_strip_command(settings), + self._get_carriage_speed_command(settings) + ]) + commands.extend([ + *self._get_shake_commands(settings), + self._get_flashes_per_well_command(settings), + *self._get_pmt_commands(settings), + *self._get_wavelength_commands(settings), + *self._get_filter_commands(settings), + self._get_calibrate_command(settings), + self._get_read_stage_command(settings), + self._get_mode_command(settings), + self._get_order_command(settings), + self._get_tag_command(settings), + *self._get_nvram_commands(settings), + ]) + + await self._send_commands(commands) + await self._read_now() + await self._wait_for_idle() + return await self._transfer_data(settings) diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py new file mode 100644 index 00000000000..43748208109 --- /dev/null +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -0,0 +1,736 @@ +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +import math + +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + +from pylabrobot.plate_reading.molecular_devices_backend import ( + MolecularDevicesBackend, + ReadMode, + ReadType, + ReadOrder, + Calibrate, + ShakeSettings, + CarriageSpeed, + PmtGain, + KineticSettings, + SpectrumSettings, + MolecularDevicesSettings, + MolecularDevicesDataCollectionAbsorbance, + MolecularDevicesDataAbsorbance, + MolecularDevicesDataCollectionFluorescence, + MolecularDevicesDataFluorescence, + MolecularDevicesDataCollectionLuminescence, + MolecularDevicesDataLuminescence, + MolecularDevicesError, + MolecularDevicesUnrecognizedCommandError, +) + +class TestMolecularDevicesBackend(unittest.TestCase): + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="COM1") + self.backend.io = self.mock_serial + + def test_setup_stop(self): + asyncio.run(self.backend.setup()) + self.mock_serial.setup.assert_called_once() + asyncio.run(self.backend.stop()) + self.mock_serial.stop.assert_called_once() + + def test_get_clear_command(self): + self.assertEqual(self.backend._get_clear_command(), "!CLEAR DATA") + + def test_get_mode_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE ENDPOINT") + + settings.read_type = ReadType.KINETIC + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE KINETIC 10 5") + + settings.read_type = ReadType.SPECTRUM + settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) + self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM 200 10 50") + + settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" + self.assertEqual(self.backend._get_mode_command(settings), "!MODE EXSPECTRUM 200 10 50") + + def test_get_wavelength_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600"]) + + settings.path_check = True + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"]) + + settings.read_mode = ReadMode.FLU + settings.excitation_wavelengths = [485] + settings.emission_wavelengths = [520] + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"]) + + settings.read_mode = ReadMode.LUM + settings.emission_wavelengths = [590] + self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EMWAVELENGTH 590"]) + + def test_get_plate_position_commands(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + cmds = self.backend._get_plate_position_commands(settings) + self.assertEqual(len(cmds), 2) + self.assertEqual(cmds[0], "!XPOS 13.380 9.000 12") + self.assertEqual(cmds[1], "!YPOS 12.240 9.000 8") + + def test_get_strip_command(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_strip_command(settings), "!STRIP 1 12") + + def test_get_shake_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE OFF"]) + + settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 5 0 0 0 0"]) + + settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 0 10 7 3 0"]) + + def test_get_carriage_speed_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 8") + settings.carriage_speed = CarriageSpeed.SLOW + self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 1") + + def test_get_read_stage_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE TOP") + settings.read_from_bottom = True + self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE BOT") + settings.read_mode = ReadMode.ABS + self.assertIsNone(self.backend._get_read_stage_command(settings)) + + def test_get_flashes_per_well_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_flashes_per_well_command(settings), "!FPW 10") + settings.read_mode = ReadMode.ABS + self.assertIsNone(self.backend._get_flashes_per_well_command(settings)) + + def test_get_pmt_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + pmt_gain=PmtGain.AUTO, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT ON"]) + settings.pmt_gain = PmtGain.HIGH + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT HIGH"]) + settings.pmt_gain = 9 + self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT 9"]) + settings.read_mode = ReadMode.ABS + self.assertEqual(self.backend._get_pmt_commands(settings), []) + + def test_get_filter_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + cutoff_filters=[self.backend._get_cutoff_filter_index_from_wavelength(535), 9], + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 8 9"]) + settings.cutoff_filters = [] + self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER ON"]) + settings.read_mode = ReadMode.ABS + settings.cutoff_filters = [515, 530] + self.assertEqual(self.backend._get_filter_commands(settings), ['!AUTOFILTER ON']) + + def test_get_calibrate_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_calibrate_command(settings), "!CALIBRATE ON") + settings.read_mode = ReadMode.FLU + self.assertEqual(self.backend._get_calibrate_command(settings), "!PMTCAL ON") + + def test_get_order_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_order_command(settings), "!ORDER COLUMN") + settings.read_order = ReadOrder.WAVELENGTH + self.assertEqual(self.backend._get_order_command(settings), "!ORDER WAVELENGTH") + + def test_get_speed_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_speed_command(settings), "!SPEED ON") + settings.speed_read = False + self.assertEqual(self.backend._get_speed_command(settings), "!SPEED OFF") + settings.read_mode = ReadMode.FLU + self.assertIsNone(self.backend._get_speed_command(settings)) + + def test_get_integration_time_commands(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.TIME, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), + ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"]) + settings.read_mode = ReadMode.ABS + self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), []) + +class Test_get_nvram_and_tag_commands(unittest.TestCase): + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="COM1") + self.backend.io = self.mock_serial + + def test_get_nvram_commands_polar(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.POLAR, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=5, + is_settling_time_on=True + ) + self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM FPSETTLETIME 5"]) + settings.is_settling_time_on = False + self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM FPSETTLETIME 0"]) + + def test_get_nvram_commands_other(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=10, + is_settling_time_on=True + ) + self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM CARCOL 10"]) + settings.is_settling_time_on = False + self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM CARCOL 100"]) + + def test_get_tag_command(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.POLAR, + read_type=ReadType.KINETIC, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=KineticSettings(interval=10, num_readings=5), + spectrum_settings=None, + ) + self.assertEqual(self.backend._get_tag_command(settings), "!TAG ON") + settings.read_type = ReadType.ENDPOINT + self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") + settings.read_mode = ReadMode.ABS + settings.read_type = ReadType.KINETIC + self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_absorbance(plate, [500])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!WAVELENGTH 500", commands) + self.assertIn("!CALIBRATE ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!SPEED OFF", commands) + self.assertIn(("!READTYPE ABSPLA", 2), commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn(("!READTYPE FLU", 1), commands) + self.assertIn('!READSTAGE TOP', commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_luminescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_luminescence(plate, [590])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!EMWAVELENGTH 590", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn(("!READTYPE LUM", 1), commands) + self.assertIn('!READSTAGE TOP', commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_fluorescence_polarization(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_fluorescence_polarization(plate, [485], [520], [515])) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn(("!READTYPE POLAR", 1), commands) + self.assertIn('!READSTAGE TOP', commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + def test_read_time_resolved_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + asyncio.run(self.backend.read_time_resolved_fluorescence(plate, [485], [520], [515], delay_time=10, integration_time=100)) + mock_send_commands.assert_called_once() + commands = mock_send_commands.call_args[0][0] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 50", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!COUNTTIMEDELAY 10", commands) + self.assertIn("!COUNTTIME 0.1", commands) + self.assertIn(("!READTYPE TIME 0 250", 1), commands) + self.assertIn('!READSTAGE TOP', commands) + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + +class TestDataParsing(unittest.TestCase): + def setUp(self): + with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): + self.backend = MolecularDevicesBackend(port="COM1") + + def test_parse_absorbance_single_wavelength(self): + data_str = """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + """ + result = self.backend._parse_data(data_str) + self.assertIsInstance(result, MolecularDevicesDataCollectionAbsorbance) + self.assertEqual(result.container_type, "96-well") + self.assertEqual(result.all_absorbance_wavelengths, [260]) + self.assertEqual(len(result.reads), 1) + read = result.reads[0] + self.assertIsInstance(read, MolecularDevicesDataAbsorbance) + self.assertEqual(read.measurement_time, 12345.6) + self.assertEqual(read.temperature, 25.1) + self.assertEqual(read.absorbance_wavelength, 260) + self.assertEqual(read.data, [[0.1, 0.3], [0.2, 0.4]]) + + def test_parse_absorbance_multiple_wavelengths(self): + data_str = """ + 12345.6\t25.1\t96-well + L:\t260\t280 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + L:\t280 + 1:\t0.5\t0.6 + 2:\t0.7\t0.8 + """ + result = self.backend._parse_data(data_str) + self.assertIsInstance(result, MolecularDevicesDataCollectionAbsorbance) + self.assertEqual(result.all_absorbance_wavelengths, [260, 280]) + self.assertEqual(len(result.reads), 2) + read1 = result.reads[0] + self.assertEqual(read1.absorbance_wavelength, 260) + self.assertEqual(read1.data, [[0.1, 0.3], [0.2, 0.4]]) + read2 = result.reads[1] + self.assertEqual(read2.absorbance_wavelength, 280) + self.assertEqual(read2.data, [[0.5, 0.7], [0.6, 0.8]]) + + def test_parse_fluorescence(self): + data_str = """ + 12345.6\t25.1\t96-well + exL:\t485 + emL:\t520 + L:\t485\t520 + 1:\t100\t200 + 2:\t300\t400 + """ + result = self.backend._parse_data(data_str) + self.assertIsInstance(result, MolecularDevicesDataCollectionFluorescence) + self.assertEqual(result.all_excitation_wavelengths, [485]) + self.assertEqual(result.all_emission_wavelengths, [520]) + self.assertEqual(len(result.reads), 1) + read = result.reads[0] + self.assertIsInstance(read, MolecularDevicesDataFluorescence) + self.assertEqual(read.excitation_wavelength, 485) + self.assertEqual(read.emission_wavelength, 520) + self.assertEqual(read.data, [[100.0, 300.0], [200.0, 400.0]]) + + def test_parse_luminescence(self): + data_str = """ + 12345.6\t25.1\t96-well + emL:\t590 + L:\t\t590 + 1:\t1000\t2000 + 2:\t3000\t4000 + """ + result = self.backend._parse_data(data_str) + self.assertIsInstance(result, MolecularDevicesDataCollectionLuminescence) + self.assertEqual(result.all_emission_wavelengths, [590]) + self.assertEqual(len(result.reads), 1) + read = result.reads[0] + self.assertIsInstance(read, MolecularDevicesDataLuminescence) + self.assertEqual(read.emission_wavelength, 590) + self.assertEqual(read.data, [[1000.0, 3000.0], [2000.0, 4000.0]]) + + def test_parse_data_with_sat_and_nan(self): + data_str = """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t#SAT + 2:\t0.3\t- + """ + result = self.backend._parse_data(data_str) + read = result.reads[0] + self.assertEqual(read.data[1][0], float('inf')) + self.assertTrue(math.isnan(read.data[1][1])) + + def test_parse_kinetic_absorbance(self): + # Mock the send_command to return two different data blocks + def data_generator(): + yield ["OK", """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + """] + yield ["OK", """ + 12355.6\t25.2\t96-well + L:\t260 + L:\t260 + 1:\t0.15\t0.25 + 2:\t0.35\t0.45 + """] + + self.backend.send_command = AsyncMock(side_effect=data_generator()) + + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.KINETIC, + kinetic_settings=KineticSettings(interval=10, num_readings=2), + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + spectrum_settings=None, + ) + + result = asyncio.run(self.backend._transfer_data(settings)) + self.assertEqual(len(result.reads), 2) + self.assertEqual(result.reads[0].data, [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result.reads[1].data, [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result.reads[0].measurement_time, 12345.6) + self.assertEqual(result.reads[1].measurement_time, 12355.6) + + +class TestErrorHandling(unittest.TestCase): + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock() + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="/dev/tty01") + self.backend.io = self.mock_serial + + async def _mock_send_command_response(self, response_str: str): + self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] + return await self.backend.send_command("!TEST") + + def test_parse_basic_errors_fail_known_error_code(self): + # Test a known error code (e.g., 107: no data to transfer) + with self.assertRaisesRegex(MolecularDevicesUnrecognizedCommandError, "Command '!TEST' failed with error 107: no data to transfer"): + asyncio.run(self._mock_send_command_response("OK\t\r\n>FAIL\t 107")) + + def test_parse_basic_errors_fail_unknown_error_code(self): + # Test an unknown error code + with self.assertRaisesRegex(MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999"): + asyncio.run(self._mock_send_command_response("FAIL\t 999")) + + def test_parse_basic_errors_fail_unparsable_error(self): + # Test an unparsable error message (e.g., not an integer code) + with self.assertRaisesRegex(MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC"): + asyncio.run(self._mock_send_command_response("FAIL\t ABC")) + + def test_parse_basic_errors_empty_response(self): + # Test an empty response from the device + self.mock_serial.readline.return_value = b"" # Simulate no response + with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): + asyncio.run(self.backend.send_command("!TEST", timeout=0.01)) # Short timeout for test + + def test_parse_basic_errors_warning_response(self): + # Test a response containing a warning + self.mock_serial.readline.side_effect = [b"OK\tWarning: Something happened>\r\n"] + # Expect no exception, but a warning logged (not directly testable with assertRaises) + # We can assert that no error is raised. + try: + asyncio.run(self.backend.send_command("!TEST")) + except MolecularDevicesError: + self.fail("MolecularDevicesError raised for a warning response") + + def test_parse_basic_errors_ok_response(self): + # Test a normal OK response + self.mock_serial.readline.side_effect = [b"OK>\r\n"] + try: + response = asyncio.run(self.backend.send_command("!TEST")) + self.assertEqual(response, ["OK"]) + except MolecularDevicesError: + self.fail("MolecularDevicesError raised for a valid OK response") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py new file mode 100644 index 00000000000..5978442d53b --- /dev/null +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -0,0 +1,39 @@ +from typing import List, Tuple + +from .molecular_devices_backend import ( + MolecularDevicesBackend, + MolecularDevicesSettings, + ReadMode +) + + +class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): + """Backend for Molecular Devices SpectraMax 384 Plus plate readers.""" + + def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + super().__init__(port, res_term_char) + + + def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: + """Get the READTYPE command and the expected number of response fields.""" + cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" + return (cmd, 1) + + def _get_nvram_commands(self, settings): + return [None] + + def _get_tag_command(self, settings): + pass + + async def read_fluorescence(self, *args, **kwargs) -> List[List[float]]: + raise NotImplementedError("Fluorescence reading is not supported.") + + async def read_luminescence(self, *args, **kwargs) -> List[List[float]]: + raise NotImplementedError("Luminescence reading is not supported.") + + async def read_fluorescence_polarization(self, *args, **kwargs) -> List[List[float]]: + raise NotImplementedError("Fluorescence polarization reading is not supported.") + + async def read_time_resolved_fluorescence(self, *args, **kwargs) -> List[List[float]]: + raise NotImplementedError("Time-resolved fluorescence reading is not supported.") + diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py new file mode 100644 index 00000000000..92e6f343feb --- /dev/null +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py @@ -0,0 +1,8 @@ +from .molecular_devices_backend import MolecularDevicesBackend + + +class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): + """Backend for Molecular Devices SpectraMax M5 plate readers.""" + + def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + super().__init__(port, res_term_char) From af1c99736af577d22896738f1b2c1a438139a119 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 15 Oct 2025 19:04:04 -0700 Subject: [PATCH 07/18] minor fixes --- .../molecular_devices_backend.py | 4 +- .../molecular_devices_backend_tests.py | 40 +++++++++---------- ...lar_devices_spectramax_384_plus_backend.py | 3 +- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 699a70df0b0..3af553695c5 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -443,7 +443,7 @@ async def _transfer_data( def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": lines = re.split(r'\r\n|\n', data_str.strip()) - lines = [l.strip() for l in lines if l.strip()] + lines = [line.strip() for line in lines if line.strip()] # 1. Parse header header_parts = lines[0].split('\t') @@ -585,7 +585,7 @@ def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: cmd += f" {ks.interval} {ks.num_readings}" elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: ss = settings.spectrum_settings - cmd= f"!MODE" + cmd= "!MODE" scan_type = ss.excitation_emission_type or "SPECTRUM" cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" return cmd diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 43748208109..455198cab88 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -397,10 +397,10 @@ def test_get_tag_command(self): settings.read_type = ReadType.KINETIC self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_absorbance(plate, [500])) @@ -420,10 +420,10 @@ def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_ mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) @@ -450,10 +450,10 @@ def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfe mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) def test_read_luminescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_luminescence(plate, [590])) @@ -475,10 +475,10 @@ def test_read_luminescence(self, mock_send_commands, mock_read_now, mock_transfe mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) def test_read_fluorescence_polarization(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence_polarization(plate, [485], [520], [515])) @@ -505,10 +505,10 @@ def test_read_fluorescence_polarization(self, mock_send_commands, mock_read_now, mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) + @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) def test_read_time_resolved_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_time_resolved_fluorescence(plate, [485], [520], [515], delay_time=10, integration_time=100)) diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index 5978442d53b..bade69bd793 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -2,8 +2,7 @@ from .molecular_devices_backend import ( MolecularDevicesBackend, - MolecularDevicesSettings, - ReadMode + MolecularDevicesSettings ) From efd96556a08045ddb9b756847aaee29f2611c5b5 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 15 Oct 2025 19:09:08 -0700 Subject: [PATCH 08/18] formatting --- .../molecular_devices_backend.py | 714 ++++++++++-------- .../molecular_devices_backend_tests.py | 550 ++++++++------ ...lar_devices_spectramax_384_plus_backend.py | 9 +- ...molecular_devices_spectramax_m5_backend.py | 2 +- 4 files changed, 730 insertions(+), 545 deletions(-) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 3af553695c5..56cf986291b 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -2,10 +2,11 @@ import logging import re import time +from abc import ABCMeta from dataclasses import dataclass, field from enum import Enum -from typing import List, Literal, Optional, Union, Tuple, Dict -from abc import ABCMeta +from typing import Dict, List, Literal, Optional, Tuple, Union + from pylabrobot.io.serial import Serial from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources.plate import Plate @@ -14,51 +15,51 @@ COMMAND_TERMINATORS: Dict[str, int] = { - "!AUTOFILTER": 1, - "!AUTOPMT": 1, - "!BAUD": 1, - "!CALIBRATE": 1, - "!CANCEL": 1, - "!CLEAR": 1, - "!CLOSE": 1, - "!CSPEED": 1, - "!REFERENCE": 1, - "!EMFILTER": 1, - "!EMWAVELENGTH": 1, - "!ERROR": 2, - "!EXWAVELENGTH": 1, - "!FPW": 1, - "!INIT": 1, - "!MODE": 1, - "!NVRAM": 1, - "!OPEN": 1, - "!ORDER": 1, - "OPTION": 2, - "!AIR_CAL": 1, - "!PMT": 1, - "!PMTCAL": 1, - "!QUEUE": 2, - "!READ": 1, - "!TOP": 1, - "!READSTAGE": 2, - "!READTYPE": 2, - "!RESEND": 1, - "!RESET": 1, - "!SHAKE": 1, - "!SPEED": 2, - "!STATUS": 2, - "!STRIP": 1, - "!TAG": 1, - "!TEMP": 2, - "!TRANSFER": 2, - "!USER_NUMBER": 2, - "!XPOS": 1, - "!YPOS": 1, - "!WAVELENGTH": 1, - "!WELLSCANMODE": 2, - "!PATHCAL": 2, - "!COUNTTIME": 1, - "!COUNTTIMEDELAY": 1, + "!AUTOFILTER": 1, + "!AUTOPMT": 1, + "!BAUD": 1, + "!CALIBRATE": 1, + "!CANCEL": 1, + "!CLEAR": 1, + "!CLOSE": 1, + "!CSPEED": 1, + "!REFERENCE": 1, + "!EMFILTER": 1, + "!EMWAVELENGTH": 1, + "!ERROR": 2, + "!EXWAVELENGTH": 1, + "!FPW": 1, + "!INIT": 1, + "!MODE": 1, + "!NVRAM": 1, + "!OPEN": 1, + "!ORDER": 1, + "OPTION": 2, + "!AIR_CAL": 1, + "!PMT": 1, + "!PMTCAL": 1, + "!QUEUE": 2, + "!READ": 1, + "!TOP": 1, + "!READSTAGE": 2, + "!READTYPE": 2, + "!RESEND": 1, + "!RESET": 1, + "!SHAKE": 1, + "!SPEED": 2, + "!STATUS": 2, + "!STRIP": 1, + "!TAG": 1, + "!TEMP": 2, + "!TRANSFER": 2, + "!USER_NUMBER": 2, + "!XPOS": 1, + "!YPOS": 1, + "!WAVELENGTH": 1, + "!WELLSCANMODE": 2, + "!PATHCAL": 2, + "!COUNTTIME": 1, + "!COUNTTIMEDELAY": 1, } @@ -69,15 +70,19 @@ class MolecularDevicesError(Exception): class MolecularDevicesUnrecognizedCommandError(MolecularDevicesError): """Unrecognized command errors sent from the computer.""" + class MolecularDevicesFirmwareError(MolecularDevicesError): """Firmware errors.""" + class MolecularDevicesHardwareError(MolecularDevicesError): """Hardware errors.""" + class MolecularDevicesMotionError(MolecularDevicesError): """Motion errors.""" + class MolecularDevicesNVRAMError(MolecularDevicesError): """NVRAM errors.""" @@ -139,6 +144,7 @@ class MolecularDevicesNVRAMError(MolecularDevicesError): class ReadMode(Enum): """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" + ABS = "ABS" FLU = "FLU" LUM = "LUM" @@ -148,6 +154,7 @@ class ReadMode(Enum): class ReadType(Enum): """The type of read to perform (e.g., Endpoint, Kinetic).""" + ENDPOINT = "ENDPOINT" KINETIC = "KINETIC" SPECTRUM = "SPECTRUM" @@ -156,12 +163,14 @@ class ReadType(Enum): class ReadOrder(Enum): """The order in which to read the plate wells.""" + COLUMN = "COLUMN" WAVELENGTH = "WAVELENGTH" class Calibrate(Enum): """The calibration mode for the read.""" + ON = "ON" ONCE = "ONCE" OFF = "OFF" @@ -169,12 +178,14 @@ class Calibrate(Enum): class CarriageSpeed(Enum): """The speed of the plate carriage.""" + NORMAL = "8" SLOW = "1" class PmtGain(Enum): """The photomultiplier tube gain setting.""" + AUTO = "ON" HIGH = "HIGH" MEDIUM = "MED" @@ -184,6 +195,7 @@ class PmtGain(Enum): @dataclass class ShakeSettings: """Settings for shaking the plate during a read.""" + before_read: bool = False before_read_duration: int = 0 between_reads: bool = False @@ -193,6 +205,7 @@ class ShakeSettings: @dataclass class KineticSettings: """Settings for kinetic reads.""" + interval: int num_readings: int @@ -200,6 +213,7 @@ class KineticSettings: @dataclass class SpectrumSettings: """Settings for spectrum reads.""" + start_wavelength: int step: int num_steps: int @@ -209,6 +223,7 @@ class SpectrumSettings: @dataclass class MolecularDevicesSettings: """A comprehensive, internal container for all plate reader settings.""" + plate: Plate = field(repr=False) read_mode: ReadMode read_type: ReadType @@ -232,64 +247,76 @@ class MolecularDevicesSettings: is_settling_time_on: bool = False - @dataclass class MolecularDevicesData: """Data from a Molecular Devices plate reader.""" + measurement_time: float temperature: float data: List[List[float]] + @dataclass class MolecularDevicesDataAbsorbance(MolecularDevicesData): """Absorbance data from a Molecular Devices plate reader.""" + absorbance_wavelength: int path_lengths: Optional[List[List[float]]] = None + @dataclass class MolecularDevicesDataFluorescence(MolecularDevicesData): """Fluorescence data from a Molecular Devices plate reader.""" + excitation_wavelength: int emission_wavelength: int + @dataclass class MolecularDevicesDataLuminescence(MolecularDevicesData): """Luminescence data from a Molecular Devices plate reader.""" + emission_wavelength: int @dataclass class MolecularDevicesDataCollection: """A collection of MolecularDevicesData objects from multiple reads.""" + container_type: str reads: List["MolecularDevicesData"] data_ordering: str + @dataclass class MolecularDevicesDataCollectionAbsorbance(MolecularDevicesDataCollection): """A collection of MolecularDevicesDataAbsorbance objects from multiple reads.""" + reads: List["MolecularDevicesDataAbsorbance"] all_absorbance_wavelengths: List[int] + @dataclass class MolecularDevicesDataCollectionFluorescence(MolecularDevicesDataCollection): """A collection of MolecularDevicesDataFluorescence objects from multiple reads.""" + reads: List["MolecularDevicesDataFluorescence"] all_excitation_wavelengths: List[int] all_emission_wavelengths: List[int] + @dataclass class MolecularDevicesDataCollectionLuminescence(MolecularDevicesDataCollection): """A collection of MolecularDevicesDataLuminescence objects from multiple reads.""" + reads: List["MolecularDevicesDataLuminescence"] all_emission_wavelengths: List[int] class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): - """Backend for Molecular Devices plate readers. - """ + """Backend for Molecular Devices plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + def __init__(self, port: str, res_term_char: bytes = b">") -> None: self.port = port self.io = Serial(self.port, baudrate=9600, timeout=0.2) self.res_term_char = res_term_char @@ -304,7 +331,9 @@ async def stop(self) -> None: def serialize(self) -> dict: return {**super().serialize(), "port": self.port} - async def send_command(self, command: str, timeout: int = 60, num_res_fields=None) -> MolecularDevicesResponse: + async def send_command( + self, command: str, timeout: int = 60, num_res_fields=None + ) -> MolecularDevicesResponse: """Send a command and receive the response, automatically determining the number of response fields. """ @@ -326,14 +355,11 @@ async def send_command(self, command: str, timeout: int = 60, num_res_fields=Non break logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) response = raw_response.decode("utf-8").strip().split(self.res_term_char.decode()) - response = [r.strip() for r in response if r.strip() != ''] + response = [r.strip() for r in response if r.strip() != ""] self._parse_basic_errors(response, command) return response - async def _send_commands( - self, - commands: List[Union[Optional[str], Tuple[str, int]]] - ) -> None: + async def _send_commands(self, commands: List[Union[Optional[str], Tuple[str, int]]]) -> None: """Send a sequence of commands to the plate reader.""" for command_info in commands: if not command_info: @@ -367,9 +393,9 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: f"Command '{command}' failed with unparsable error: {response[0]}" ) - if 'OK' not in response[0]: + if "OK" not in response[0]: raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") - elif 'warning' in response[0].lower(): + elif "warning" in response[0].lower(): logger.warning("Warning for command '%s': %s", command, response) async def open(self) -> None: @@ -392,7 +418,7 @@ async def clear_error_log(self) -> None: async def get_temperature(self) -> Tuple[float, float]: res = await self.send_command("!TEMP") parts = res[1].split() - return (float(parts[1]), float(parts[0])) # current, set_point + return (float(parts[1]), float(parts[0])) # current, set_point async def set_temperature(self, temperature: float) -> None: if not (0 <= temperature <= 45): @@ -401,6 +427,7 @@ async def set_temperature(self, temperature: float) -> None: async def get_firmware_version(self) -> str: await self.send_command("!OPTION") + async def start_shake(self) -> None: await self.send_command("!SHAKE NOW") @@ -411,17 +438,20 @@ async def _read_now(self) -> None: await self.send_command("!READ") async def _transfer_data( - self, - settings: MolecularDevicesSettings + self, settings: MolecularDevicesSettings ) -> "MolecularDevicesDataCollection": """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each reading and combine them into a single collection. """ - if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) \ - or (settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings): - num_readings = settings.kinetic_settings.num_readings if settings.kinetic_settings \ + if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( + settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings + ): + num_readings = ( + settings.kinetic_settings.num_readings + if settings.kinetic_settings else settings.spectrum_settings.num_steps + ) all_reads: List["MolecularDevicesData"] = [] collection: Optional["MolecularDevicesDataCollection"] = None @@ -442,11 +472,11 @@ async def _transfer_data( return self._parse_data(data_str) def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": - lines = re.split(r'\r\n|\n', data_str.strip()) + lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] # 1. Parse header - header_parts = lines[0].split('\t') + header_parts = lines[0].split("\t") measurement_time = float(header_parts[0]) temperature = float(header_parts[1]) container_type = header_parts[2] @@ -459,17 +489,17 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": while line_idx < len(lines): line = lines[line_idx] if line.startswith("L:") and line_idx == 1: - parts = line.split('\t') + parts = line.split("\t") for part in parts[1:]: if part.strip(): absorbance_wavelengths.append(int(part.strip())) elif line.startswith("exL:"): - parts = line.split('\t') + parts = line.split("\t") for part in parts[1:]: if part.strip() and part.strip().isdigit(): excitation_wavelengths.append(int(part.strip())) elif line.startswith("emL:"): - parts = line.split('\t') + parts = line.split("\t") for part in parts[1:]: if part.strip(): emission_wavelengths.append(int(part.strip())) @@ -477,30 +507,30 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": # Data section started break line_idx += 1 - data_collection=[] - cur_read_wavelengths=[] + data_collection = [] + cur_read_wavelengths = [] # 3. Parse data data_columns = [] # The data section starts at line_idx for i in range(line_idx, len(lines)): line = lines[i] if line.startswith("L:"): - #start of a new data with different wavelength - cur_read_wavelengths.append(line.split('\t')[1:]) + # start of a new data with different wavelength + cur_read_wavelengths.append(line.split("\t")[1:]) if i > line_idx and data_columns: data_collection.append(data_columns) - data_columns=[] + data_columns = [] match = re.match(r"^\s*(\d+):\s*(.*)", line) if match: - values_str = re.split(r'\s+', match.group(2).strip()) - values=[] + values_str = re.split(r"\s+", match.group(2).strip()) + values = [] for v in values_str: - if v.strip().replace('.','',1).isdigit(): + if v.strip().replace(".", "", 1).isdigit(): values.append(float(v.strip())) elif v.strip() == "#SAT": - values.append(float('inf')) + values.append(float("inf")) else: - values.append(float('nan')) + values.append(float("nan")) data_columns.append(values) if data_columns: data_collection.append(data_columns) @@ -518,59 +548,63 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": data_collection_transposed.append(data_rows) if absorbance_wavelengths: - reads=[] - for i,data_rows in enumerate(data_collection_transposed): + reads = [] + for i, data_rows in enumerate(data_collection_transposed): wl = int(cur_read_wavelengths[i][0]) - reads.append(MolecularDevicesDataAbsorbance( - measurement_time=measurement_time, - temperature=temperature, - data=data_rows, - absorbance_wavelength=wl, - path_lengths=None - )) + reads.append( + MolecularDevicesDataAbsorbance( + measurement_time=measurement_time, + temperature=temperature, + data=data_rows, + absorbance_wavelength=wl, + path_lengths=None, + ) + ) return MolecularDevicesDataCollectionAbsorbance( - container_type=container_type, reads=reads, all_absorbance_wavelengths=absorbance_wavelengths, - data_ordering="row-major" + data_ordering="row-major", ) elif excitation_wavelengths and emission_wavelengths: - reads=[] - for i,data_rows in enumerate(data_collection_transposed): + reads = [] + for i, data_rows in enumerate(data_collection_transposed): ex_wl = int(cur_read_wavelengths[i][0]) em_wl = int(cur_read_wavelengths[i][1]) - reads.append(MolecularDevicesDataFluorescence( - measurement_time=measurement_time, - temperature=temperature, - data=data_rows, - excitation_wavelength=ex_wl, - emission_wavelength=em_wl - )) + reads.append( + MolecularDevicesDataFluorescence( + measurement_time=measurement_time, + temperature=temperature, + data=data_rows, + excitation_wavelength=ex_wl, + emission_wavelength=em_wl, + ) + ) return MolecularDevicesDataCollectionFluorescence( container_type=container_type, reads=reads, all_excitation_wavelengths=excitation_wavelengths, all_emission_wavelengths=emission_wavelengths, - data_ordering="row-major" + data_ordering="row-major", ) elif emission_wavelengths: - reads=[] - for i,data_rows in enumerate(data_collection_transposed): + reads = [] + for i, data_rows in enumerate(data_collection_transposed): em_wl = int(cur_read_wavelengths[i][1]) - reads.append(MolecularDevicesDataLuminescence( - measurement_time=measurement_time, - temperature=temperature, - - data=data_rows, - emission_wavelength=em_wl - )) + reads.append( + MolecularDevicesDataLuminescence( + measurement_time=measurement_time, + temperature=temperature, + data=data_rows, + emission_wavelength=em_wl, + ) + ) return MolecularDevicesDataCollectionLuminescence( container_type=container_type, reads=reads, all_emission_wavelengths=emission_wavelengths, - data_ordering="row-major" + data_ordering="row-major", ) # Default to generic MolecularDevicesData if no specific wavelengths found raise ValueError("Unable to determine data type from response.") @@ -585,7 +619,7 @@ def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: cmd += f" {ks.interval} {ks.num_readings}" elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: ss = settings.spectrum_settings - cmd= "!MODE" + cmd = "!MODE" scan_type = ss.excitation_emission_type or "SPECTRUM" cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" return cmd @@ -612,9 +646,9 @@ def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> Li plate = settings.plate 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.") + raise ValueError("Plate must have at least 2 rows and 2 columns to calculate well spacing.") top_left_well = plate.get_item(0) - top_left_well_center=top_left_well.location + top_left_well.get_anchor(x="c", y="c") + 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 @@ -642,8 +676,8 @@ def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: between_duration = 0 wait_duration = 0 return [ - f"!SHAKE {shake_mode}", - f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" + f"!SHAKE {shake_mode}", + f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0", ] def _get_carriage_speed_command(self, settings: MolecularDevicesSettings) -> str: @@ -670,7 +704,10 @@ def _get_pmt_commands(self, settings: MolecularDevicesSettings) -> List[str]: return ["!AUTOPMT OFF", f"!PMT {gain_val}"] def _get_filter_commands(self, settings: MolecularDevicesSettings) -> List[str]: - if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) and settings.cutoff_filters: + if ( + settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) + and settings.cutoff_filters + ): cf_str = " ".join(map(str, settings.cutoff_filters)) return ["!AUTOFILTER OFF", f"!EMFILTER {cf_str}"] return ["!AUTOFILTER ON"] @@ -728,16 +765,10 @@ def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str return (cmd, num_res_fields) def _get_integration_time_commands( - self, - settings: MolecularDevicesSettings, - delay_time: int, - integration_time: int + self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int ) -> List[str]: if settings.read_mode == ReadMode.TIME: - return [ - f"!COUNTTIMEDELAY {delay_time}", - f"!COUNTTIME {integration_time * 0.001}" - ] + return [f"!COUNTTIMEDELAY {delay_time}", f"!COUNTTIME {integration_time * 0.001}"] return [] def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: @@ -745,22 +776,22 @@ def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: # This map is a direct translation of the `EmissionCutoff.CutoffFilter` in MaxlineModel.cs # (min_wavelength, max_wavelength, cutoff_filter_index) FILTERS = [ - (0, 322, 1), - (325, 415, 16), - (420, 435, 2), - (435, 455, 3), - (455, 475, 4), - (475, 495, 5), - (495, 515, 6), - (515, 530, 7), - (530, 550, 8), - (550, 570, 9), - (570, 590, 10), - (590, 610, 11), - (610, 630, 12), - (630, 665, 13), - (665, 695, 14), - (695, 900, 15), + (0, 322, 1), + (325, 415, 16), + (420, 435, 2), + (435, 455, 3), + (455, 475, 4), + (475, 495, 5), + (495, 515, 6), + (515, 530, 7), + (530, 550, 8), + (550, 570, 9), + (570, 590, 10), + (590, 610, 11), + (610, 630, 12), + (630, 665, 13), + (665, 695, 14), + (695, 900, 15), ] for min_wl, max_wl, cutoff_filter_index in FILTERS: if min_wl <= wavelength < max_wl: @@ -779,39 +810,51 @@ async def _wait_for_idle(self, timeout: int = 120): await asyncio.sleep(1) async def read_absorbance( - self, - plate: Plate, - wavelengths: List[Union[int, Tuple[int, bool]]], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - speed_read: bool = False, - path_check: bool = False, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - is_settling_time_on: bool = False + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.ABS, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, speed_read=speed_read, path_check=path_check, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - wavelengths=wavelengths, cuvette=cuvette, - settling_time=settling_time, is_settling_time_on=is_settling_time_on + plate=plate, + read_mode=ReadMode.ABS, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + speed_read=speed_read, + path_check=path_check, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + wavelengths=wavelengths, + cuvette=cuvette, + settling_time=settling_time, + is_settling_time_on=is_settling_time_on, ) commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] if not cuvette: # commands.extend() - commands.extend([ - *self._get_plate_position_commands(settings), - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ + commands.extend( + [ + *self._get_plate_position_commands(settings), + self._get_strip_command(settings), + self._get_carriage_speed_command(settings), + ] + ) + commands.extend( + [ *self._get_shake_commands(settings), *self._get_wavelength_commands(settings), self._get_calibrate_command(settings), @@ -820,8 +863,9 @@ async def read_absorbance( self._get_speed_command(settings), self._get_tag_command(settings), *self._get_nvram_commands(settings), - self._get_readtype_command(settings) - ]) + self._get_readtype_command(settings), + ] + ) await self._send_commands(commands) await self._read_now() @@ -829,46 +873,56 @@ async def read_absorbance( return await self._transfer_data(settings) async def read_fluorescence( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - is_settling_time_on: bool = False + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: - '''use _get_cutoff_filter_index_from_wavelength for cutoff_filters''' + """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.FLU, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False, - settling_time=settling_time, is_settling_time_on=is_settling_time_on + plate=plate, + read_mode=ReadMode.FLU, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + is_settling_time_on=is_settling_time_on, ) commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] # commands.append(self._get_read_stage_command(settings)) if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ + commands.extend(self._get_plate_position_commands(settings)) + commands.extend( + [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] + ) + commands.extend( + [ *self._get_shake_commands(settings), self._get_flashes_per_well_command(settings), *self._get_pmt_commands(settings), @@ -880,8 +934,9 @@ async def read_fluorescence( self._get_order_command(settings), self._get_tag_command(settings), *self._get_nvram_commands(settings), - self._get_readtype_command(settings) - ]) + self._get_readtype_command(settings), + ] + ) await self._send_commands(commands) await self._read_now() @@ -889,43 +944,53 @@ async def read_fluorescence( return await self._transfer_data(settings) async def read_luminescence( - self, - plate: Plate, - emission_wavelengths: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 0, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - is_settling_time_on: bool = False + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.LUM, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - emission_wavelengths=emission_wavelengths, cuvette=cuvette, speed_read=False, - settling_time=settling_time, is_settling_time_on=is_settling_time_on + plate=plate, + read_mode=ReadMode.LUM, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + is_settling_time_on=is_settling_time_on, ) commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_read_stage_command(settings) + self._get_clear_command(), + self._get_read_stage_command(settings), ] if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ + commands.extend(self._get_plate_position_commands(settings)) + commands.extend( + [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] + ) + commands.extend( + [ *self._get_shake_commands(settings), *self._get_pmt_commands(settings), *self._get_wavelength_commands(settings), @@ -935,8 +1000,9 @@ async def read_luminescence( self._get_order_command(settings), self._get_tag_command(settings), *self._get_nvram_commands(settings), - self._get_readtype_command(settings) - ]) + self._get_readtype_command(settings), + ] + ) await self._send_commands(commands) await self._read_now() @@ -944,45 +1010,55 @@ async def read_luminescence( return await self._transfer_data(settings) async def read_fluorescence_polarization( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - is_settling_time_on: bool = False + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.POLAR, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False, - settling_time=settling_time, is_settling_time_on=is_settling_time_on + plate=plate, + read_mode=ReadMode.POLAR, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + is_settling_time_on=is_settling_time_on, ) commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] # commands.append(self._get_read_stage_command(settings)) if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ + commands.extend(self._get_plate_position_commands(settings)) + commands.extend( + [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] + ) + commands.extend( + [ *self._get_shake_commands(settings), self._get_flashes_per_well_command(settings), *self._get_pmt_commands(settings), @@ -994,8 +1070,9 @@ async def read_fluorescence_polarization( self._get_order_command(settings), self._get_tag_command(settings), *self._get_nvram_commands(settings), - self._get_readtype_command(settings) - ]) + self._get_readtype_command(settings), + ] + ) await self._send_commands(commands) await self._read_now() @@ -1003,50 +1080,60 @@ async def read_fluorescence_polarization( return await self._transfer_data(settings) async def read_time_resolved_fluorescence( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - delay_time: int, - integration_time: int, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 50, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - is_settling_time_on: bool = False + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.TIME, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False, - settling_time=settling_time, is_settling_time_on=is_settling_time_on + plate=plate, + read_mode=ReadMode.TIME, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + is_settling_time_on=is_settling_time_on, ) commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_readtype_command(settings), - *self._get_integration_time_commands(settings, delay_time, integration_time) + self._get_clear_command(), + self._get_readtype_command(settings), + *self._get_integration_time_commands(settings, delay_time, integration_time), ] if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ + commands.extend(self._get_plate_position_commands(settings)) + commands.extend( + [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] + ) + commands.extend( + [ *self._get_shake_commands(settings), self._get_flashes_per_well_command(settings), *self._get_pmt_commands(settings), @@ -1058,7 +1145,8 @@ async def read_time_resolved_fluorescence( self._get_order_command(settings), self._get_tag_command(settings), *self._get_nvram_commands(settings), - ]) + ] + ) await self._send_commands(commands) await self._read_now() diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 455198cab88..842fc4609ce 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -1,31 +1,31 @@ import asyncio +import math import unittest from unittest.mock import AsyncMock, MagicMock, patch -import math - -from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul from pylabrobot.plate_reading.molecular_devices_backend import ( - MolecularDevicesBackend, - ReadMode, - ReadType, - ReadOrder, - Calibrate, - ShakeSettings, - CarriageSpeed, - PmtGain, - KineticSettings, - SpectrumSettings, - MolecularDevicesSettings, - MolecularDevicesDataCollectionAbsorbance, - MolecularDevicesDataAbsorbance, - MolecularDevicesDataCollectionFluorescence, - MolecularDevicesDataFluorescence, - MolecularDevicesDataCollectionLuminescence, - MolecularDevicesDataLuminescence, - MolecularDevicesError, - MolecularDevicesUnrecognizedCommandError, + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesDataAbsorbance, + MolecularDevicesDataCollectionAbsorbance, + MolecularDevicesDataCollectionFluorescence, + MolecularDevicesDataCollectionLuminescence, + MolecularDevicesDataFluorescence, + MolecularDevicesDataLuminescence, + MolecularDevicesError, + MolecularDevicesSettings, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, ) +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + class TestMolecularDevicesBackend(unittest.TestCase): def setUp(self): @@ -50,16 +50,16 @@ def test_get_clear_command(self): def test_get_mode_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_mode_command(settings), "!MODE ENDPOINT") @@ -76,27 +76,31 @@ def test_get_mode_command(self): def test_get_wavelength_commands(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - wavelengths=[500, (600, True)], - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600"]) settings.path_check = True - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"]) + self.assertEqual( + self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"] + ) settings.read_mode = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"]) + self.assertEqual( + self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"] + ) settings.read_mode = ReadMode.LUM settings.emission_wavelengths = [590] @@ -105,16 +109,16 @@ def test_get_wavelength_commands(self): def test_get_plate_position_commands(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) cmds = self.backend._get_plate_position_commands(settings) self.assertEqual(len(cmds), 2) @@ -124,31 +128,31 @@ def test_get_plate_position_commands(self): def test_get_strip_command(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_strip_command(settings), "!STRIP 1 12") def test_get_shake_commands(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE OFF"]) @@ -161,16 +165,16 @@ def test_get_shake_commands(self): def test_get_carriage_speed_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 8") settings.carriage_speed = CarriageSpeed.SLOW @@ -178,16 +182,16 @@ def test_get_carriage_speed_command(self): def test_get_read_stage_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE TOP") settings.read_from_bottom = True @@ -197,17 +201,17 @@ def test_get_read_stage_command(self): def test_get_flashes_per_well_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - flashes_per_well=10, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_flashes_per_well_command(settings), "!FPW 10") settings.read_mode = ReadMode.ABS @@ -215,17 +219,17 @@ def test_get_flashes_per_well_command(self): def test_get_pmt_commands(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - pmt_gain=PmtGain.AUTO, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + pmt_gain=PmtGain.AUTO, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT ON"]) settings.pmt_gain = PmtGain.HIGH @@ -237,37 +241,39 @@ def test_get_pmt_commands(self): def test_get_filter_commands(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - cutoff_filters=[self.backend._get_cutoff_filter_index_from_wavelength(535), 9], - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + cutoff_filters=[self.backend._get_cutoff_filter_index_from_wavelength(535), 9], + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual( + self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 8 9"] ) - self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 8 9"]) settings.cutoff_filters = [] self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER ON"]) settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] - self.assertEqual(self.backend._get_filter_commands(settings), ['!AUTOFILTER ON']) + self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER ON"]) def test_get_calibrate_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_calibrate_command(settings), "!CALIBRATE ON") settings.read_mode = ReadMode.FLU @@ -275,16 +281,16 @@ def test_get_calibrate_command(self): def test_get_order_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_order_command(settings), "!ORDER COLUMN") settings.read_order = ReadOrder.WAVELENGTH @@ -292,16 +298,16 @@ def test_get_order_command(self): def test_get_speed_command(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=True, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, ) self.assertEqual(self.backend._get_speed_command(settings), "!SPEED ON") settings.speed_read = False @@ -311,22 +317,25 @@ def test_get_speed_command(self): def test_get_integration_time_commands(self): settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.TIME, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.TIME, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + self.assertEqual( + self.backend._get_integration_time_commands(settings, 10, 100), + ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"], ) - self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), - ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"]) settings.read_mode = ReadMode.ABS self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), []) + class Test_get_nvram_and_tag_commands(unittest.TestCase): def setUp(self): self.mock_serial = MagicMock() @@ -352,7 +361,7 @@ def test_get_nvram_commands_polar(self): kinetic_settings=None, spectrum_settings=None, settling_time=5, - is_settling_time_on=True + is_settling_time_on=True, ) self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM FPSETTLETIME 5"]) settings.is_settling_time_on = False @@ -371,7 +380,7 @@ def test_get_nvram_commands_other(self): kinetic_settings=None, spectrum_settings=None, settling_time=10, - is_settling_time_on=True + is_settling_time_on=True, ) self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM CARCOL 10"]) settings.is_settling_time_on = False @@ -397,11 +406,26 @@ def test_get_tag_command(self): settings.read_type = ReadType.KINETIC self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", + new_callable=AsyncMock, + ) + def test_read_absorbance( + self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_absorbance(plate, [500])) mock_send_commands.assert_called_once() @@ -420,11 +444,26 @@ def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_ mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", + new_callable=AsyncMock, + ) + def test_read_fluorescence( + self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) mock_send_commands.assert_called_once() @@ -445,16 +484,31 @@ def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfe self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) self.assertIn(("!READTYPE FLU", 1), commands) - self.assertIn('!READSTAGE TOP', commands) + self.assertIn("!READSTAGE TOP", commands) mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_luminescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", + new_callable=AsyncMock, + ) + def test_read_luminescence( + self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_luminescence(plate, [590])) mock_send_commands.assert_called_once() @@ -470,16 +524,31 @@ def test_read_luminescence(self, mock_send_commands, mock_read_now, mock_transfe self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) self.assertIn(("!READTYPE LUM", 1), commands) - self.assertIn('!READSTAGE TOP', commands) + self.assertIn("!READSTAGE TOP", commands) mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_fluorescence_polarization(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", + new_callable=AsyncMock, + ) + def test_read_fluorescence_polarization( + self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence_polarization(plate, [485], [520], [515])) mock_send_commands.assert_called_once() @@ -500,18 +569,37 @@ def test_read_fluorescence_polarization(self, mock_send_commands, mock_read_now, self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) self.assertIn(("!READTYPE POLAR", 1), commands) - self.assertIn('!READSTAGE TOP', commands) + self.assertIn("!READSTAGE TOP", commands) mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_time_resolved_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", + new_callable=AsyncMock, + ) + def test_read_time_resolved_fluorescence( + self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_time_resolved_fluorescence(plate, [485], [520], [515], delay_time=10, integration_time=100)) + asyncio.run( + self.backend.read_time_resolved_fluorescence( + plate, [485], [520], [515], delay_time=10, integration_time=100 + ) + ) mock_send_commands.assert_called_once() commands = mock_send_commands.call_args[0][0] self.assertIn("!CLEAR DATA", commands) @@ -532,11 +620,12 @@ def test_read_time_resolved_fluorescence(self, mock_send_commands, mock_read_now self.assertIn("!COUNTTIMEDELAY 10", commands) self.assertIn("!COUNTTIME 0.1", commands) self.assertIn(("!READTYPE TIME 0 250", 1), commands) - self.assertIn('!READSTAGE TOP', commands) + self.assertIn("!READSTAGE TOP", commands) mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() + class TestDataParsing(unittest.TestCase): def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): @@ -631,40 +720,46 @@ def test_parse_data_with_sat_and_nan(self): """ result = self.backend._parse_data(data_str) read = result.reads[0] - self.assertEqual(read.data[1][0], float('inf')) + self.assertEqual(read.data[1][0], float("inf")) self.assertTrue(math.isnan(read.data[1][1])) def test_parse_kinetic_absorbance(self): # Mock the send_command to return two different data blocks def data_generator(): - yield ["OK", """ + yield [ + "OK", + """ 12345.6\t25.1\t96-well L:\t260 L:\t260 1:\t0.1\t0.2 2:\t0.3\t0.4 - """] - yield ["OK", """ + """, + ] + yield [ + "OK", + """ 12355.6\t25.2\t96-well L:\t260 L:\t260 1:\t0.15\t0.25 2:\t0.35\t0.45 - """] + """, + ] self.backend.send_command = AsyncMock(side_effect=data_generator()) settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.KINETIC, - kinetic_settings=KineticSettings(interval=10, num_readings=2), - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - spectrum_settings=None, + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.KINETIC, + kinetic_settings=KineticSettings(interval=10, num_readings=2), + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + spectrum_settings=None, ) result = asyncio.run(self.backend._transfer_data(settings)) @@ -693,24 +788,31 @@ async def _mock_send_command_response(self, response_str: str): def test_parse_basic_errors_fail_known_error_code(self): # Test a known error code (e.g., 107: no data to transfer) - with self.assertRaisesRegex(MolecularDevicesUnrecognizedCommandError, "Command '!TEST' failed with error 107: no data to transfer"): + with self.assertRaisesRegex( + MolecularDevicesUnrecognizedCommandError, + "Command '!TEST' failed with error 107: no data to transfer", + ): asyncio.run(self._mock_send_command_response("OK\t\r\n>FAIL\t 107")) def test_parse_basic_errors_fail_unknown_error_code(self): # Test an unknown error code - with self.assertRaisesRegex(MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999"): + with self.assertRaisesRegex( + MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999" + ): asyncio.run(self._mock_send_command_response("FAIL\t 999")) def test_parse_basic_errors_fail_unparsable_error(self): # Test an unparsable error message (e.g., not an integer code) - with self.assertRaisesRegex(MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC"): + with self.assertRaisesRegex( + MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC" + ): asyncio.run(self._mock_send_command_response("FAIL\t ABC")) def test_parse_basic_errors_empty_response(self): # Test an empty response from the device - self.mock_serial.readline.return_value = b"" # Simulate no response + self.mock_serial.readline.return_value = b"" # Simulate no response with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): - asyncio.run(self.backend.send_command("!TEST", timeout=0.01)) # Short timeout for test + asyncio.run(self.backend.send_command("!TEST", timeout=0.01)) # Short timeout for test def test_parse_basic_errors_warning_response(self): # Test a response containing a warning diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index bade69bd793..311fa7ea13f 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -1,18 +1,14 @@ from typing import List, Tuple -from .molecular_devices_backend import ( - MolecularDevicesBackend, - MolecularDevicesSettings -) +from .molecular_devices_backend import MolecularDevicesBackend, MolecularDevicesSettings class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): """Backend for Molecular Devices SpectraMax 384 Plus plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + def __init__(self, port: str, res_term_char: bytes = b">") -> None: super().__init__(port, res_term_char) - def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: """Get the READTYPE command and the expected number of response fields.""" cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" @@ -35,4 +31,3 @@ async def read_fluorescence_polarization(self, *args, **kwargs) -> List[List[flo async def read_time_resolved_fluorescence(self, *args, **kwargs) -> List[List[float]]: raise NotImplementedError("Time-resolved fluorescence reading is not supported.") - diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py index 92e6f343feb..f19ed1448f7 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py @@ -4,5 +4,5 @@ class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): """Backend for Molecular Devices SpectraMax M5 plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b'>') -> None: + def __init__(self, port: str, res_term_char: bytes = b">") -> None: super().__init__(port, res_term_char) From e99ee1fa2c75dc657656607c17d27d014c6efcc0 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 15 Oct 2025 19:21:53 -0700 Subject: [PATCH 09/18] remove extra files --- .../plate_reading/molecularDevices_backend.py | 723 ------------------ .../molecularDevices_backend_tests.py | 380 --------- 2 files changed, 1103 deletions(-) delete mode 100644 pylabrobot/plate_reading/molecularDevices_backend.py delete mode 100644 pylabrobot/plate_reading/molecularDevices_backend_tests.py diff --git a/pylabrobot/plate_reading/molecularDevices_backend.py b/pylabrobot/plate_reading/molecularDevices_backend.py deleted file mode 100644 index d97eb921142..00000000000 --- a/pylabrobot/plate_reading/molecularDevices_backend.py +++ /dev/null @@ -1,723 +0,0 @@ -import asyncio -import logging -import time -from dataclasses import dataclass, field -from enum import Enum -from typing import List, Literal, Optional, Union, Tuple, Dict - -from pylabrobot.io.serial import Serial -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources.plate import Plate - -logger = logging.getLogger("pylabrobot") - -# This map is a direct translation of the `ConstructCommandList` method in MaxlineModel.cs -# It maps the base command string to the number of terminating characters (response fields) expected. -COMMAND_TERMINATORS: Dict[str, int] = { - "!AUTOFILTER": 1, - "!AUTOPMT": 1, - "!BAUD": 1, - "!CALIBRATE": 1, - "!CANCEL": 1, - "!CLEAR": 1, - "!CLOSE": 1, - "!CSPEED": 1, - "!REFERENCE": 1, - "!EMFILTER": 1, - "!EMWAVELENGTH": 1, - "!ERROR": 2, - "!EXWAVELENGTH": 1, - "!FPW": 1, - "!INIT": 1, - "!MODE": 1, - "!NVRAM": 1, - "!OPEN": 1, - "!ORDER": 1, - "!AIR_CAL": 1, - "!PMT": 1, - "!PMTCAL": 1, - "!QUEUE": 2, - "!READ": 1, - "!TOP": 1, - "!READSTAGE": 2, - "!READTYPE": 2, - "!RESEND": 1, - "!RESET": 1, - "!SHAKE": 1, - "!SPEED": 2, - "!STATUS": 2, - "!STRIP": 1, - "!TAG": 1, - "!TEMP": 2, - "!TRANSFER": 2, - "!USER_NUMBER": 2, - "!XPOS": 1, - "!YPOS": 1, - "!WAVELENGTH": 1, - "!WELLSCANMODE": 2, - "!PATHCAL": 2, - "!COUNTTIME": 1, - "!COUNTTIMEDELAY": 1, -} - - -class MolecularDevicesError(Exception): - """Exceptions raised by a Molecular Devices plate reader.""" - - -MolecularDevicesResponse = List[str] - - -class ReadMode(Enum): - """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" - ABS = "ABS" - FLU = "FLU" - LUM = "LUM" - POLAR = "POLAR" - TIME = "TIME" - - -class ReadType(Enum): - """The type of read to perform (e.g., Endpoint, Kinetic).""" - ENDPOINT = "ENDPOINT" - KINETIC = "KINETIC" - SPECTRUM = "SPECTRUM" - WELL_SCAN = "WELLSCAN" - - -class ReadOrder(Enum): - """The order in which to read the plate wells.""" - COLUMN = "COLUMN" - WAVELENGTH = "WAVELENGTH" - - -class Calibrate(Enum): - """The calibration mode for the read.""" - ON = "ON" - ONCE = "ONCE" - OFF = "OFF" - - -class CarriageSpeed(Enum): - """The speed of the plate carriage.""" - NORMAL = "8" - SLOW = "1" - - -class PmtGain(Enum): - """The photomultiplier tube gain setting.""" - AUTO = "ON" - HIGH = "HIGH" - MEDIUM = "MED" - LOW = "LOW" - - -@dataclass -class ShakeSettings: - """Settings for shaking the plate during a read.""" - before_read: bool = False - before_read_duration: int = 0 - between_reads: bool = False - between_reads_duration: int = 0 - - -@dataclass -class KineticSettings: - """Settings for kinetic reads.""" - interval: int - num_readings: int - - -@dataclass -class SpectrumSettings: - """Settings for spectrum reads.""" - start_wavelength: int - step: int - num_steps: int - excitation_emission_type: Optional[Literal["EXSPECTRUM", "EMSPECTRUM"]] = None - - -@dataclass -class MolecularDevicesSettings: - """A comprehensive, internal container for all plate reader settings.""" - plate: Plate = field(repr=False) - read_mode: ReadMode - read_type: ReadType - read_order: ReadOrder - calibrate: Calibrate - shake_settings: Optional[ShakeSettings] - carriage_speed: CarriageSpeed - speed_read: bool - kinetic_settings: Optional[KineticSettings] - spectrum_settings: Optional[SpectrumSettings] - wavelengths: List[Union[int, Tuple[int, bool]]] = field(default_factory=list) - excitation_wavelengths: List[int] = field(default_factory=list) - emission_wavelengths: List[int] = field(default_factory=list) - cutoff_filters: List[int] = field(default_factory=list) - path_check: bool = False - read_from_bottom: bool = False - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO - flashes_per_well: int = 1 - cuvette: bool = False - - -class MolecularDevicesBackend(PlateReaderBackend): - """Backend for Molecular Devices Spectralmax plate readers. - - This backend is a faithful implementation based on the "Maxline" command set - as detailed in the reverse-engineered C# source code. - """ - - def __init__(self, port: str, res_term_char: bytes = b'>') -> None: - self.port = port - self.io = Serial(self.port, baudrate=9600, timeout=1) - self.res_term_char = res_term_char - - async def setup(self) -> None: - await self.io.setup() - await self.send_command("!") - - async def stop(self) -> None: - await self.io.stop() - - def serialize(self) -> dict: - return {**super().serialize(), "port": self.port} - - async def send_command(self, command: str, timeout: int = 60, num_res_fields=None) -> MolecularDevicesResponse: - """Send a command and receive the response, automatically determining the number of - response fields. - """ - base_command = command.split(" ")[0] - if num_res_fields is None: - num_res_fields = COMMAND_TERMINATORS.get(base_command, 1) - else: - num_res_fields = max(1, num_res_fields) - - await self.io.write(command.encode() + b"\r") - raw_response = b"" - timeout_time = time.time() + timeout - while True: - raw_response += await self.io.readline() - await asyncio.sleep(0.001) - if time.time() > timeout_time: - raise TimeoutError(f"Timeout waiting for response to command: {command}") - if raw_response.count(self.res_term_char) >= num_res_fields: - break - logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) - response = raw_response.decode("utf-8").strip().split(self.res_term_char.decode()) - response = [r.strip() for r in response if r.strip() != ''] - self._parse_basic_errors(response, command) - return response - - async def _send_commands( - self, - commands: List[Union[Optional[str], Tuple[str, int]]] - ) -> None: - """Send a sequence of commands to the plate reader.""" - for command_info in commands: - if not command_info: - continue - if isinstance(command_info, tuple): - command, num_res_fields = command_info - await self.send_command(command, num_res_fields=num_res_fields) - else: - await self.send_command(command_info) - - def _parse_basic_errors(self, response: List[str], command: str) -> None: - if not response or 'OK' not in response[0]: - raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") - elif 'warning' in response[0].lower(): - logger.warning("Warning for command '%s': %s", command, response) - - async def open(self) -> None: - await self.send_command("!OPEN") - - async def close(self, plate: Optional[Plate] = None) -> None: - await self.send_command("!CLOSE") - - async def get_status(self) -> List[str]: - res = await self.send_command("!STATUS") - return res[1].split() - - async def read_error_log(self) -> str: - res = await self.send_command("!ERROR") - return res[1] - - async def clear_error_log(self) -> None: - await self.send_command("!CLEAR ERROR") - - async def get_temperature(self) -> Tuple[float, float]: - res = await self.send_command("!TEMP") - parts = res[1].split() - return (float(parts[1]), float(parts[0])) # current, set_point - - async def set_temperature(self, temperature: float) -> None: - if not (0 <= temperature <= 45): - raise ValueError("Temperature must be between 0 and 45°C.") - await self.send_command(f"!TEMP {temperature}") - - async def get_firmware_version(self) -> str: - await self.io.write(b"!OPTIONS\r") - raw_response = b"" - timeout_time = time.time() + 10 - while True: - raw_response += await self.io.read() - await asyncio.sleep(0.001) - if time.time() > timeout_time: - raise TimeoutError("Timeout waiting for firmware version.") - if raw_response.count(self.res_term_char) >= 1: # !OPTIONS is not in the map, assume 1 - break - response_str = raw_response.decode("utf-8") - lines = response_str.strip().split('\n') - return lines[5].strip() if len(lines) >= 6 else lines[-1].strip().replace(">", "").strip() - - async def start_shake(self) -> None: - await self.send_command("!SHAKE NOW") - - async def stop_shake(self) -> None: - await self.send_command("!SHAKE STOP") - - async def _read_now(self) -> None: - await self.send_command("!READ") - - async def _transfer_data(self) -> str: - res = await self.send_command("!TRANSFER") - return res[1] - - def _parse_data(self, data_str: str) -> List[List[float]]: - data = [] - rows = data_str.strip().split('\r') - for row in rows: - if not row: - continue - try: - values_str = row.strip().split('\t') - if len(values_str) == 1: - values_str = row.strip().split() - values = [float(v) for v in values_str] - data.append(values) - except (ValueError, IndexError): - logger.warning("Could not parse row: %s", row) - return data - - def _get_clear_command(self) -> str: - return "!CLEAR DATA" - - def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: - cmd = f"!MODE {settings.read_type.value}" - if settings.read_type == ReadType.KINETIC and settings.kinetic_settings: - ks = settings.kinetic_settings - cmd += f" {ks.interval} {ks.num_readings}" - elif settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings: - ss = settings.spectrum_settings - scan_type = ss.excitation_emission_type or "SPECTRUM" - cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" - return cmd - - def _get_wavelength_commands(self, settings: MolecularDevicesSettings) -> List[str]: - if settings.read_mode == ReadMode.ABS: - wl_parts = [] - for wl in settings.wavelengths: - wl_parts.append(f"F{wl[0]}" if isinstance(wl, tuple) and wl[1] else str(wl)) - wl_str = " ".join(wl_parts) - if settings.path_check: - wl_str += " 900 998" - return [f"!WAVELENGTH {wl_str}"] - if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): - ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) - em_wl_str = " ".join(map(str, settings.emission_wavelengths)) - return [f"!EXWAVELENGTH {ex_wl_str}", f"!EMWAVELENGTH {em_wl_str}"] - if settings.read_mode == ReadMode.LUM: - wl_str = " ".join(map(str, settings.emission_wavelengths)) - return [f"!EMWAVELENGTH {wl_str}"] - return [] - - def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> List[str]: - plate = settings.plate - 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) - 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 - dx = loc_A2.x - loc_A1.x - dy = loc_A1.y - loc_B1.y - - x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" - y_pos_cmd = f"!YPOS {size_y-top_left_well_center.y:.3f} {dy:.3f} {num_rows}" - return [x_pos_cmd, y_pos_cmd] - - def _get_strip_command(self, settings: MolecularDevicesSettings) -> str: - return f"!STRIP 1 {settings.plate.num_items_x}" - - def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: - if not settings.shake_settings: - return ["!SHAKE OFF"] - ss = settings.shake_settings - shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" - before_duration = ss.before_read_duration if ss.before_read else 0 - ki = settings.kinetic_settings.interval if settings.kinetic_settings else 0 - if ss.between_reads and ki > 0: - between_duration = ss.between_reads_duration - wait_duration = ki - between_duration - else: - between_duration = 0 - wait_duration = 0 - return [ - f"!SHAKE {shake_mode}", - f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" - ] - - def _get_carriage_speed_command(self, settings: MolecularDevicesSettings) -> str: - return f"!CSPEED {settings.carriage_speed.value}" - - def _get_read_stage_command(self, settings: MolecularDevicesSettings) -> Optional[str]: - if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - stage = "BOT" if settings.read_from_bottom else "TOP" - return f"!READSTAGE {stage}" - return None - - def _get_flashes_per_well_command(self, settings: MolecularDevicesSettings) -> Optional[str]: - if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - return f"!FPW {settings.flashes_per_well}" - return None - - def _get_pmt_commands(self, settings: MolecularDevicesSettings) -> List[str]: - if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - return [] - gain = settings.pmt_gain - if gain == PmtGain.AUTO: - return ["!AUTOPMT ON"] - gain_val = gain.value if isinstance(gain, PmtGain) else gain - return ["!AUTOPMT OFF", f"!PMT {gain_val}"] - - def _get_filter_commands(self, settings: MolecularDevicesSettings) -> List[str]: - if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) and settings.cutoff_filters: - cf_str = " ".join(map(str, settings.cutoff_filters)) - return ["!AUTOFILTER OFF", f"!EMFILTER {cf_str}"] - return [] - - def _get_calibrate_command(self, settings: MolecularDevicesSettings) -> str: - if settings.read_mode == ReadMode.ABS: - return f"!CALIBRATE {settings.calibrate.value}" - return f"!PMTCAL {settings.calibrate.value}" - - def _get_order_command(self, settings: MolecularDevicesSettings) -> str: - return f"!ORDER {settings.read_order.value}" - - def _get_speed_command(self, settings: MolecularDevicesSettings) -> Optional[str]: - if settings.read_mode == ReadMode.ABS: - mode = "ON" if settings.speed_read else "OFF" - return f"!SPEED {mode}" - return None - - def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: - """Get the READTYPE command and the expected number of response fields.""" - cuvette = settings.cuvette - num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) - - if settings.read_mode == ReadMode.ABS: - cmd = f"!READTYPE ABS{'CUV' if cuvette else 'PLA'}" - elif settings.read_mode == ReadMode.FLU: - cmd = f"!READTYPE FLU{'CUV' if cuvette else ''}" - num_res_fields = 2 if cuvette else 1 - elif settings.read_mode == ReadMode.LUM: - cmd = f"!READTYPE LUM{'CUV' if cuvette else ''}" - num_res_fields = 2 if cuvette else 1 - elif settings.read_mode == ReadMode.POLAR: - cmd = "!READTYPE POLAR" - elif settings.read_mode == ReadMode.TIME: - cmd = "!READTYPE TIME 0 250" - num_res_fields = 1 - else: - raise ValueError(f"Unsupported read mode: {settings.read_mode}") - - return (cmd, num_res_fields) - - def _get_integration_time_commands( - self, - settings: MolecularDevicesSettings, - delay_time: int, - integration_time: int - ) -> List[str]: - if settings.read_mode == ReadMode.TIME: - return [ - f"!COUNTTIMEDELAY {delay_time}", - f"!COUNTTIME {integration_time * 0.001}" - ] - return [] - - async def _wait_for_idle(self, timeout: int = 120): - """Wait for the plate reader to become idle.""" - start_time = time.time() - while True: - if time.time() - start_time > timeout: - raise TimeoutError("Timeout waiting for plate reader to become idle.") - status = await self.get_status() - if status and status[1] == "IDLE": - break - await asyncio.sleep(1) - - async def read_absorbance( - self, - plate: Plate, - wavelengths: List[Union[int, Tuple[int, bool]]], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - speed_read: bool = False, - path_check: bool = False, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False - ) -> List[List[float]]: - settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.ABS, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, speed_read=speed_read, path_check=path_check, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - wavelengths=wavelengths, cuvette=cuvette - ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] - if not cuvette: - # commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ - *self._get_shake_commands(settings), - *self._get_wavelength_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_speed_command(settings), - self._get_readtype_command(settings) - ]) - - await self._send_commands(commands) - await self._read_now() - await self._wait_for_idle() - data_str = await self._transfer_data() - return self._parse_data(data_str) - - async def read_fluorescence( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False - ) -> List[List[float]]: - settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.FLU, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False - ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] - # commands.append(self._get_read_stage_command(settings)) - if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_readtype_command(settings) - ]) - - await self._send_commands(commands) - await self._read_now() - await self._wait_for_idle() - data_str = await self._transfer_data() - return self._parse_data(data_str) - - async def read_luminescence( - self, - plate: Plate, - emission_wavelengths: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False - ) -> List[List[float]]: - settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.LUM, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - emission_wavelengths=emission_wavelengths, cuvette=cuvette, speed_read=False - ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_read_stage_command(settings) - ] - if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_readtype_command(settings) - ]) - - await self._send_commands(commands) - await self._read_now() - await self._wait_for_idle() - data_str = await self._transfer_data() - return self._parse_data(data_str) - - async def read_fluorescence_polarization( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False - ) -> List[List[float]]: - settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.POLAR, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False - ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] - # commands.append(self._get_read_stage_command(settings)) - if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_readtype_command(settings) - ]) - - await self._send_commands(commands) - await self._read_now() - await self._wait_for_idle() - data_str = await self._transfer_data() - return self._parse_data(data_str) - - async def read_time_resolved_fluorescence( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - delay_time: int, - integration_time: int, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False - ) -> List[List[float]]: - settings = MolecularDevicesSettings( - plate=plate, read_mode=ReadMode.TIME, read_type=read_type, - read_order=read_order, calibrate=calibrate, shake_settings=shake_settings, - carriage_speed=carriage_speed, read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette,speed_read=False - ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_readtype_command(settings), - *self._get_integration_time_commands(settings, delay_time, integration_time) - ] - if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend([ - self._get_strip_command(settings), - self._get_carriage_speed_command(settings) - ]) - commands.extend([ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings) - ]) - - await self._send_commands(commands) - await self._read_now() - await self._wait_for_idle() - data_str = await self._transfer_data() - return self._parse_data(data_str) diff --git a/pylabrobot/plate_reading/molecularDevices_backend_tests.py b/pylabrobot/plate_reading/molecularDevices_backend_tests.py deleted file mode 100644 index 46ee3d0ed66..00000000000 --- a/pylabrobot/plate_reading/molecularDevices_backend_tests.py +++ /dev/null @@ -1,380 +0,0 @@ -import asyncio -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul - -from pylabrobot.plate_reading.molecularDevices_backend import ( - MolecularDevicesBackend, - ReadMode, - ReadType, - ReadOrder, - Calibrate, - ShakeSettings, - CarriageSpeed, - PmtGain, - KineticSettings, - SpectrumSettings, - MolecularDevicesSettings, -) - -class TestMolecularDevicesBackend(unittest.TestCase): - def setUp(self): - self.mock_serial = MagicMock() - self.mock_serial.setup = AsyncMock() - self.mock_serial.stop = AsyncMock() - self.mock_serial.write = AsyncMock() - self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") - - with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): - self.backend = MolecularDevicesBackend(port="COM1") - self.backend.io = self.mock_serial - - def test_setup_stop(self): - asyncio.run(self.backend.setup()) - self.mock_serial.setup.assert_called_once() - asyncio.run(self.backend.stop()) - self.mock_serial.stop.assert_called_once() - - def test_get_clear_command(self): - self.assertEqual(self.backend._get_clear_command(), "!CLEAR DATA") - - def test_get_mode_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE ENDPOINT") - - settings.read_type = ReadType.KINETIC - settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE KINETIC 10 5") - - settings.read_type = ReadType.SPECTRUM - settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM SPECTRUM 200 10 50") - - settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" - self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM EXSPECTRUM 200 10 50") - - def test_get_wavelength_commands(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - wavelengths=[500, (600, True)], - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600"]) - - settings.path_check = True - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"]) - - settings.read_mode = ReadMode.FLU - settings.excitation_wavelengths = [485] - settings.emission_wavelengths = [520] - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"]) - - settings.read_mode = ReadMode.LUM - settings.emission_wavelengths = [590] - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EMWAVELENGTH 590"]) - - def test_get_plate_position_commands(self): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - cmds = self.backend._get_plate_position_commands(settings) - self.assertEqual(len(cmds), 2) - self.assertEqual(cmds[0], "!XPOS 13.380 9.000 12") - self.assertEqual(cmds[1], "!YPOS 12.240 9.000 8") - - def test_get_strip_command(self): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_strip_command(settings), "!STRIP 1 12") - - def test_get_shake_commands(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE OFF"]) - - settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 5 0 0 0 0"]) - - settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) - settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 0 10 7 3 0"]) - - def test_get_carriage_speed_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 8") - settings.carriage_speed = CarriageSpeed.SLOW - self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 1") - - def test_get_read_stage_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE TOP") - settings.read_from_bottom = True - self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE BOT") - settings.read_mode = ReadMode.ABS - self.assertIsNone(self.backend._get_read_stage_command(settings)) - - def test_get_flashes_per_well_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - flashes_per_well=10, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_flashes_per_well_command(settings), "!FPW 10") - settings.read_mode = ReadMode.ABS - self.assertIsNone(self.backend._get_flashes_per_well_command(settings)) - - def test_get_pmt_commands(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - pmt_gain=PmtGain.AUTO, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT ON"]) - settings.pmt_gain = PmtGain.HIGH - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT HIGH"]) - settings.pmt_gain = 9 - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT 9"]) - settings.read_mode = ReadMode.ABS - self.assertEqual(self.backend._get_pmt_commands(settings), []) - - def test_get_filter_commands(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - cutoff_filters=[515, 530], - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 515 530"]) - settings.cutoff_filters = [] - self.assertEqual(self.backend._get_filter_commands(settings), []) - settings.read_mode = ReadMode.ABS - settings.cutoff_filters = [515, 530] - self.assertEqual(self.backend._get_filter_commands(settings), []) - - def test_get_calibrate_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_calibrate_command(settings), "!CALIBRATE ON") - settings.read_mode = ReadMode.FLU - self.assertEqual(self.backend._get_calibrate_command(settings), "!PMTCAL ON") - - def test_get_order_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_order_command(settings), "!ORDER COLUMN") - settings.read_order = ReadOrder.WAVELENGTH - self.assertEqual(self.backend._get_order_command(settings), "!ORDER WAVELENGTH") - - def test_get_speed_command(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=True, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_speed_command(settings), "!SPEED ON") - settings.speed_read = False - self.assertEqual(self.backend._get_speed_command(settings), "!SPEED OFF") - settings.read_mode = ReadMode.FLU - self.assertIsNone(self.backend._get_speed_command(settings)) - - def test_get_integration_time_commands(self): - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.TIME, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), - ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"]) - settings.read_mode = ReadMode.ABS - self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), []) - - def test_parse_data(self): - data_str = "1.0\t2.0\t3.0\r\n4.0\t5.0\t6.0\r\n" - self.assertEqual(self.backend._parse_data(data_str), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) - data_str_space = "1.0 2.0 3.0\r\n4.0 5.0 6.0\r\n" - self.assertEqual(self.backend._parse_data(data_str_space), [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) - - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_absorbance(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_absorbance(plate, [500])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] - self.assertIn("!CLEAR DATA", commands) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!WAVELENGTH 500", commands) - self.assertIn("!CALIBRATE ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!SPEED OFF", commands) - self.assertIn(("!READTYPE ABSPLA", 2), commands) - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="") - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock) - @patch("pylabrobot.plate_reading.molecularDevices_backend.MolecularDevicesBackend._send_commands", new_callable=AsyncMock) - def test_read_fluorescence(self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] - self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!FPW 10", commands) - self.assertIn("!AUTOPMT ON", commands) - self.assertIn("!EXWAVELENGTH 485", commands) - self.assertIn("!EMWAVELENGTH 520", commands) - self.assertIn("!AUTOFILTER OFF", commands) - self.assertIn("!EMFILTER 515", commands) - self.assertIn("!PMTCAL ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn(("!READTYPE FLU", 1), commands) - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - -if __name__ == "__main__": - unittest.main() \ No newline at end of file From d2ec61af55dc0cc349c847ac6b84afeaa4701bbb Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Thu, 16 Oct 2025 20:39:50 -0700 Subject: [PATCH 10/18] refactor, minor fixes --- .../molecular_devices_backend.py | 380 ++++++++---------- .../molecular_devices_backend_tests.py | 340 +++++++++------- ...lar_devices_spectramax_384_plus_backend.py | 18 +- ...molecular_devices_spectramax_m5_backend.py | 4 +- 4 files changed, 373 insertions(+), 369 deletions(-) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 56cf986291b..051f9ebaec2 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -13,7 +13,7 @@ logger = logging.getLogger("pylabrobot") - +RES_TERM_CHAR = b'>' COMMAND_TERMINATORS: Dict[str, int] = { "!AUTOFILTER": 1, "!AUTOPMT": 1, @@ -244,7 +244,7 @@ class MolecularDevicesSettings: flashes_per_well: int = 1 cuvette: bool = False settling_time: int = 0 - is_settling_time_on: bool = False + @dataclass @@ -316,10 +316,10 @@ class MolecularDevicesDataCollectionLuminescence(MolecularDevicesDataCollection) class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): """Backend for Molecular Devices plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b">") -> None: + def __init__(self, port: str) -> None: self.port = port self.io = Serial(self.port, baudrate=9600, timeout=0.2) - self.res_term_char = res_term_char + async def setup(self) -> None: await self.io.setup() @@ -351,25 +351,14 @@ async def send_command( await asyncio.sleep(0.001) if time.time() > timeout_time: raise TimeoutError(f"Timeout waiting for response to command: {command}") - if raw_response.count(self.res_term_char) >= num_res_fields: + if raw_response.count(RES_TERM_CHAR) >= num_res_fields: break logger.debug("[plate reader] Command: %s, Response: %s", command, raw_response) - response = raw_response.decode("utf-8").strip().split(self.res_term_char.decode()) + response = raw_response.decode("utf-8").strip().split(RES_TERM_CHAR.decode()) response = [r.strip() for r in response if r.strip() != ""] self._parse_basic_errors(response, command) return response - async def _send_commands(self, commands: List[Union[Optional[str], Tuple[str, int]]]) -> None: - """Send a sequence of commands to the plate reader.""" - for command_info in commands: - if not command_info: - continue - if isinstance(command_info, tuple): - command, num_res_fields = command_info - await self.send_command(command, num_res_fields=num_res_fields) - else: - await self.send_command(command_info) - def _parse_basic_errors(self, response: List[str], command: str) -> None: if not response: raise MolecularDevicesError(f"Command '{command}' failed with empty response.") @@ -384,8 +373,7 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: if error_code in ERROR_CODES: message, err_class = ERROR_CODES[error_code] raise err_class(f"Command '{command}' failed with error {error_code}: {message}") - else: - raise MolecularDevicesError( + raise MolecularDevicesError( f"Command '{command}' failed with unknown error code: {error_code}" ) except (ValueError, IndexError): @@ -395,7 +383,7 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: if "OK" not in response[0]: raise MolecularDevicesError(f"Command '{command}' failed with response: {response}") - elif "warning" in response[0].lower(): + if "warning" in response[0].lower(): logger.warning("Warning for command '%s': %s", command, response) async def open(self) -> None: @@ -466,10 +454,10 @@ async def _transfer_data( collection.reads = all_reads return collection - else: - res = await self.send_command("!TRANSFER") - data_str = res[1] - return self._parse_data(data_str) + + res = await self.send_command("!TRANSFER") + data_str = res[1] + return self._parse_data(data_str) def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": lines = re.split(r"\r\n|\n", data_str.strip()) @@ -609,10 +597,10 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": # Default to generic MolecularDevicesData if no specific wavelengths found raise ValueError("Unable to determine data type from response.") - def _get_clear_command(self) -> str: - return "!CLEAR DATA" + async def _set_clear(self) -> None: + await self.send_command("!CLEAR DATA") - def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: + async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = f"!MODE {settings.read_type.value}" if settings.read_type == ReadType.KINETIC and settings.kinetic_settings: ks = settings.kinetic_settings @@ -622,9 +610,9 @@ def _get_mode_command(self, settings: MolecularDevicesSettings) -> str: cmd = "!MODE" scan_type = ss.excitation_emission_type or "SPECTRUM" cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" - return cmd + await self.send_command(cmd) - def _get_wavelength_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: wl_parts = [] for wl in settings.wavelengths: @@ -632,17 +620,17 @@ def _get_wavelength_commands(self, settings: MolecularDevicesSettings) -> List[s wl_str = " ".join(wl_parts) if settings.path_check: wl_str += " 900 998" - return [f"!WAVELENGTH {wl_str}"] + await self.send_command(f"!WAVELENGTH {wl_str}") if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) em_wl_str = " ".join(map(str, settings.emission_wavelengths)) - return [f"!EXWAVELENGTH {ex_wl_str}", f"!EMWAVELENGTH {em_wl_str}"] + await self.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self.send_command(f"!EMWAVELENGTH {em_wl_str}") if settings.read_mode == ReadMode.LUM: wl_str = " ".join(map(str, settings.emission_wavelengths)) - return [f"!EMWAVELENGTH {wl_str}"] - return [] + await self.send_command(f"!EMWAVELENGTH {wl_str}") - def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: plate = settings.plate 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: @@ -657,14 +645,16 @@ def _get_plate_position_commands(self, settings: MolecularDevicesSettings) -> Li x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" y_pos_cmd = f"!YPOS {size_y-top_left_well_center.y:.3f} {dy:.3f} {num_rows}" - return [x_pos_cmd, y_pos_cmd] + await self.send_command(x_pos_cmd) + await self.send_command(y_pos_cmd) - def _get_strip_command(self, settings: MolecularDevicesSettings) -> str: - return f"!STRIP 1 {settings.plate.num_items_x}" + async def _set_strip(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!STRIP 1 {settings.plate.num_items_x}") - def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_shake(self, settings: MolecularDevicesSettings) -> None: if not settings.shake_settings: - return ["!SHAKE OFF"] + await self.send_command("!SHAKE OFF") + return ss = settings.shake_settings shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" before_duration = ss.before_read_duration if ss.before_read else 0 @@ -675,73 +665,74 @@ def _get_shake_commands(self, settings: MolecularDevicesSettings) -> List[str]: else: between_duration = 0 wait_duration = 0 - return [ - f"!SHAKE {shake_mode}", - f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0", - ] + await self.send_command(f"!SHAKE {shake_mode}") + await self.send_command(f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0") - def _get_carriage_speed_command(self, settings: MolecularDevicesSettings) -> str: - return f"!CSPEED {settings.carriage_speed.value}" + async def _set_carriage_speed(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!CSPEED {settings.carriage_speed.value}") - def _get_read_stage_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): stage = "BOT" if settings.read_from_bottom else "TOP" - return f"!READSTAGE {stage}" - return None + await self.send_command(f"!READSTAGE {stage}") - def _get_flashes_per_well_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - return f"!FPW {settings.flashes_per_well}" - return None + await self.send_command(f"!FPW {settings.flashes_per_well}") - def _get_pmt_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - return [] + return gain = settings.pmt_gain if gain == PmtGain.AUTO: - return ["!AUTOPMT ON"] - gain_val = gain.value if isinstance(gain, PmtGain) else gain - return ["!AUTOPMT OFF", f"!PMT {gain_val}"] + await self.send_command("!AUTOPMT ON") + else: + gain_val = gain.value if isinstance(gain, PmtGain) else gain + await self.send_command("!AUTOPMT OFF") + await self.send_command(f"!PMT {gain_val}") - def _get_filter_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_filter(self, settings: MolecularDevicesSettings) -> None: if ( settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME) and settings.cutoff_filters ): cf_str = " ".join(map(str, settings.cutoff_filters)) - return ["!AUTOFILTER OFF", f"!EMFILTER {cf_str}"] - return ["!AUTOFILTER ON"] + await self.send_command("!AUTOFILTER OFF") + await self.send_command(f"!EMFILTER {cf_str}") + else: + await self.send_command("!AUTOFILTER ON") - def _get_calibrate_command(self, settings: MolecularDevicesSettings) -> str: + async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: - return f"!CALIBRATE {settings.calibrate.value}" - return f"!PMTCAL {settings.calibrate.value}" + await self.send_command(f"!CALIBRATE {settings.calibrate.value}") + else: + await self.send_command(f"!PMTCAL {settings.calibrate.value}") - def _get_order_command(self, settings: MolecularDevicesSettings) -> str: - return f"!ORDER {settings.read_order.value}" + async def _set_order(self, settings: MolecularDevicesSettings) -> None: + await self.send_command(f"!ORDER {settings.read_order.value}") - def _get_speed_command(self, settings: MolecularDevicesSettings) -> Optional[str]: + async def _set_speed(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: mode = "ON" if settings.speed_read else "OFF" - return f"!SPEED {mode}" - return None + await self.send_command(f"!SPEED {mode}") - def _get_nvram_commands(self, settings: MolecularDevicesSettings) -> List[str]: + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR: command = "FPSETTLETIME" - value = settings.settling_time if settings.is_settling_time_on else 0 + value = settings.settling_time else: command = "CARCOL" - value = settings.settling_time if settings.is_settling_time_on else 100 - return [f"!NVRAM {command} {value}"] + value = settings.settling_time if settings.settling_time > 100 else 100 + await self.send_command(f"!NVRAM {command} {value}") - def _get_tag_command(self, settings: MolecularDevicesSettings) -> str: + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: - return "!TAG ON" - return "!TAG OFF" + await self.send_command("!TAG ON") + else: + await self.send_command("!TAG OFF") - def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: - """Get the READTYPE command and the expected number of response fields.""" + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + """Set the READTYPE command and the expected number of response fields.""" cuvette = settings.cuvette num_res_fields = COMMAND_TERMINATORS.get("!READTYPE", 2) @@ -762,14 +753,14 @@ def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str else: raise ValueError(f"Unsupported read mode: {settings.read_mode}") - return (cmd, num_res_fields) + await self.send_command(cmd, num_res_fields=num_res_fields) - def _get_integration_time_commands( + async def _set_integration_time( self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int - ) -> List[str]: + ) -> None: if settings.read_mode == ReadMode.TIME: - return [f"!COUNTTIMEDELAY {delay_time}", f"!COUNTTIME {integration_time * 0.001}"] - return [] + await self.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self.send_command(f"!COUNTTIME {integration_time * 0.001}") def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: """Converts a wavelength to a cutoff filter index.""" @@ -798,7 +789,7 @@ def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: return cutoff_filter_index raise ValueError(f"No cutoff filter found for wavelength {wavelength}") - async def _wait_for_idle(self, timeout: int = 120): + async def _wait_for_idle(self, timeout: int = 600): """Wait for the plate reader to become idle.""" start_time = time.time() while True: @@ -824,7 +815,7 @@ async def read_absorbance( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, - is_settling_time_on: bool = False, + timeout: int = 600, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( plate=plate, @@ -841,35 +832,25 @@ async def read_absorbance( wavelengths=wavelengths, cuvette=cuvette, settling_time=settling_time, - is_settling_time_on=is_settling_time_on, ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] + await self._set_clear() if not cuvette: - # commands.extend() - commands.extend( - [ - *self._get_plate_position_commands(settings), - self._get_strip_command(settings), - self._get_carriage_speed_command(settings), - ] - ) - commands.extend( - [ - *self._get_shake_commands(settings), - *self._get_wavelength_commands(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_speed_command(settings), - self._get_tag_command(settings), - *self._get_nvram_commands(settings), - self._get_readtype_command(settings), - ] - ) + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_wavelengths(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_speed(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) - await self._send_commands(commands) await self._read_now() - await self._wait_for_idle() + await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_fluorescence( @@ -890,7 +871,7 @@ async def read_fluorescence( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, - is_settling_time_on: bool = False, + timeout: int = 600, ) -> MolecularDevicesDataCollection: """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" settings = MolecularDevicesSettings( @@ -912,35 +893,28 @@ async def read_fluorescence( cuvette=cuvette, speed_read=False, settling_time=settling_time, - is_settling_time_on=is_settling_time_on, ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] - # commands.append(self._get_read_stage_command(settings)) + await self._set_clear() if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend( - [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] - ) - commands.extend( - [ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_read_stage_command(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_tag_command(settings), - *self._get_nvram_commands(settings), - self._get_readtype_command(settings), - ] - ) + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) - await self._send_commands(commands) await self._read_now() - await self._wait_for_idle() + await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_luminescence( @@ -959,7 +933,7 @@ async def read_luminescence( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, - is_settling_time_on: bool = False, + timeout: int = 600, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( plate=plate, @@ -978,35 +952,28 @@ async def read_luminescence( cuvette=cuvette, speed_read=False, settling_time=settling_time, - is_settling_time_on=is_settling_time_on, ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_read_stage_command(settings), - ] + await self._set_clear() + await self._set_read_stage(settings) + if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend( - [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] - ) - commands.extend( - [ - *self._get_shake_commands(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - self._get_read_stage_command(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_tag_command(settings), - *self._get_nvram_commands(settings), - self._get_readtype_command(settings), - ] - ) + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) - await self._send_commands(commands) await self._read_now() - await self._wait_for_idle() + await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_fluorescence_polarization( @@ -1027,7 +994,7 @@ async def read_fluorescence_polarization( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, - is_settling_time_on: bool = False, + timeout: int = 600, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( plate=plate, @@ -1047,36 +1014,29 @@ async def read_fluorescence_polarization( cutoff_filters=cutoff_filters, cuvette=cuvette, speed_read=False, - settling_time=settling_time, - is_settling_time_on=is_settling_time_on, + settling_time=settling_time ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [self._get_clear_command()] - # commands.append(self._get_read_stage_command(settings)) + await self._set_clear() if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend( - [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] - ) - commands.extend( - [ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_read_stage_command(settings), - self._get_calibrate_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_tag_command(settings), - *self._get_nvram_commands(settings), - self._get_readtype_command(settings), - ] - ) + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) - await self._send_commands(commands) await self._read_now() - await self._wait_for_idle() + await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_time_resolved_fluorescence( @@ -1099,7 +1059,6 @@ async def read_time_resolved_fluorescence( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, - is_settling_time_on: bool = False, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( plate=plate, @@ -1120,35 +1079,28 @@ async def read_time_resolved_fluorescence( cuvette=cuvette, speed_read=False, settling_time=settling_time, - is_settling_time_on=is_settling_time_on, ) - commands: List[Union[Optional[str], Tuple[str, int]]] = [ - self._get_clear_command(), - self._get_readtype_command(settings), - *self._get_integration_time_commands(settings, delay_time, integration_time), - ] + await self._set_clear() + await self._set_readtype(settings) + await self._set_integration_time(settings, delay_time, integration_time) + if not cuvette: - commands.extend(self._get_plate_position_commands(settings)) - commands.extend( - [self._get_strip_command(settings), self._get_carriage_speed_command(settings)] - ) - commands.extend( - [ - *self._get_shake_commands(settings), - self._get_flashes_per_well_command(settings), - *self._get_pmt_commands(settings), - *self._get_wavelength_commands(settings), - *self._get_filter_commands(settings), - self._get_calibrate_command(settings), - self._get_read_stage_command(settings), - self._get_mode_command(settings), - self._get_order_command(settings), - self._get_tag_command(settings), - *self._get_nvram_commands(settings), - ] - ) + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_calibrate(settings) + await self._set_read_stage(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) - await self._send_commands(commands) await self._read_now() await self._wait_for_idle() return await self._transfer_data(settings) diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 842fc4609ce..8feb8665613 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -1,7 +1,7 @@ import asyncio import math import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from pylabrobot.plate_reading.molecular_devices_backend import ( Calibrate, @@ -38,17 +38,22 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="COM1") self.backend.io = self.mock_serial + self.backend.send_command = AsyncMock() def test_setup_stop(self): + # un-mock send_command for this test + self.backend.send_command = AsyncMock(wraps=self.backend.send_command) asyncio.run(self.backend.setup()) self.mock_serial.setup.assert_called_once() + self.backend.send_command.assert_called_with("!") asyncio.run(self.backend.stop()) self.mock_serial.stop.assert_called_once() - def test_get_clear_command(self): - self.assertEqual(self.backend._get_clear_command(), "!CLEAR DATA") + def test_set_clear(self): + asyncio.run(self.backend._set_clear()) + self.backend.send_command.assert_called_once_with("!CLEAR DATA") - def test_get_mode_command(self): + def test_set_mode(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -61,20 +66,27 @@ def test_get_mode_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE ENDPOINT") + asyncio.run(self.backend._set_mode(settings)) + self.backend.send_command.assert_called_once_with("!MODE ENDPOINT") + self.backend.send_command.reset_mock() settings.read_type = ReadType.KINETIC settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE KINETIC 10 5") + asyncio.run(self.backend._set_mode(settings)) + self.backend.send_command.assert_called_once_with("!MODE KINETIC 10 5") + self.backend.send_command.reset_mock() settings.read_type = ReadType.SPECTRUM settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) - self.assertEqual(self.backend._get_mode_command(settings), "!MODE SPECTRUM 200 10 50") + asyncio.run(self.backend._set_mode(settings)) + self.backend.send_command.assert_called_once_with("!MODE SPECTRUM 200 10 50") + self.backend.send_command.reset_mock() settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" - self.assertEqual(self.backend._get_mode_command(settings), "!MODE EXSPECTRUM 200 10 50") + asyncio.run(self.backend._set_mode(settings)) + self.backend.send_command.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") - def test_get_wavelength_commands(self): + def test_set_wavelengths(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -88,25 +100,30 @@ def test_get_wavelength_commands(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600"]) + asyncio.run(self.backend._set_wavelengths(settings)) + self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600") + self.backend.send_command.reset_mock() settings.path_check = True - self.assertEqual( - self.backend._get_wavelength_commands(settings), ["!WAVELENGTH 500 F600 900 998"] - ) + asyncio.run(self.backend._set_wavelengths(settings)) + self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600 900 998") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] - self.assertEqual( - self.backend._get_wavelength_commands(settings), ["!EXWAVELENGTH 485", "!EMWAVELENGTH 520"] + asyncio.run(self.backend._set_wavelengths(settings)) + self.backend.send_command.assert_has_calls( + [call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")] ) + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.LUM settings.emission_wavelengths = [590] - self.assertEqual(self.backend._get_wavelength_commands(settings), ["!EMWAVELENGTH 590"]) + asyncio.run(self.backend._set_wavelengths(settings)) + self.backend.send_command.assert_called_once_with("!EMWAVELENGTH 590") - def test_get_plate_position_commands(self): + def test_set_plate_position(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( plate=plate, @@ -120,12 +137,12 @@ def test_get_plate_position_commands(self): kinetic_settings=None, spectrum_settings=None, ) - cmds = self.backend._get_plate_position_commands(settings) - self.assertEqual(len(cmds), 2) - self.assertEqual(cmds[0], "!XPOS 13.380 9.000 12") - self.assertEqual(cmds[1], "!YPOS 12.240 9.000 8") + asyncio.run(self.backend._set_plate_position(settings)) + self.backend.send_command.assert_has_calls( + [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] + ) - def test_get_strip_command(self): + def test_set_strip(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( plate=plate, @@ -139,9 +156,10 @@ def test_get_strip_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_strip_command(settings), "!STRIP 1 12") + asyncio.run(self.backend._set_strip(settings)) + self.backend.send_command.assert_called_once_with("!STRIP 1 12") - def test_get_shake_commands(self): + def test_set_shake(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -154,16 +172,21 @@ def test_get_shake_commands(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE OFF"]) + asyncio.run(self.backend._set_shake(settings)) + self.backend.send_command.assert_called_once_with("!SHAKE OFF") + self.backend.send_command.reset_mock() settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 5 0 0 0 0"]) + asyncio.run(self.backend._set_shake(settings)) + self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) + self.backend.send_command.reset_mock() settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - self.assertEqual(self.backend._get_shake_commands(settings), ["!SHAKE ON", "!SHAKE 0 10 7 3 0"]) + asyncio.run(self.backend._set_shake(settings)) + self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) - def test_get_carriage_speed_command(self): + def test_set_carriage_speed(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -176,11 +199,14 @@ def test_get_carriage_speed_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 8") + asyncio.run(self.backend._set_carriage_speed(settings)) + self.backend.send_command.assert_called_once_with("!CSPEED 8") + self.backend.send_command.reset_mock() settings.carriage_speed = CarriageSpeed.SLOW - self.assertEqual(self.backend._get_carriage_speed_command(settings), "!CSPEED 1") + asyncio.run(self.backend._set_carriage_speed(settings)) + self.backend.send_command.assert_called_once_with("!CSPEED 1") - def test_get_read_stage_command(self): + def test_set_read_stage(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -193,13 +219,18 @@ def test_get_read_stage_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE TOP") + asyncio.run(self.backend._set_read_stage(settings)) + self.backend.send_command.assert_called_once_with("!READSTAGE TOP") + self.backend.send_command.reset_mock() settings.read_from_bottom = True - self.assertEqual(self.backend._get_read_stage_command(settings), "!READSTAGE BOT") + asyncio.run(self.backend._set_read_stage(settings)) + self.backend.send_command.assert_called_once_with("!READSTAGE BOT") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - self.assertIsNone(self.backend._get_read_stage_command(settings)) + asyncio.run(self.backend._set_read_stage(settings)) + self.backend.send_command.assert_not_called() - def test_get_flashes_per_well_command(self): + def test_set_flashes_per_well(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -213,11 +244,14 @@ def test_get_flashes_per_well_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_flashes_per_well_command(settings), "!FPW 10") + asyncio.run(self.backend._set_flashes_per_well(settings)) + self.backend.send_command.assert_called_once_with("!FPW 10") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - self.assertIsNone(self.backend._get_flashes_per_well_command(settings)) + asyncio.run(self.backend._set_flashes_per_well(settings)) + self.backend.send_command.assert_not_called() - def test_get_pmt_commands(self): + def test_set_pmt(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -231,15 +265,22 @@ def test_get_pmt_commands(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT ON"]) + asyncio.run(self.backend._set_pmt(settings)) + self.backend.send_command.assert_called_once_with("!AUTOPMT ON") + self.backend.send_command.reset_mock() settings.pmt_gain = PmtGain.HIGH - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT HIGH"]) + asyncio.run(self.backend._set_pmt(settings)) + self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) + self.backend.send_command.reset_mock() settings.pmt_gain = 9 - self.assertEqual(self.backend._get_pmt_commands(settings), ["!AUTOPMT OFF", "!PMT 9"]) + asyncio.run(self.backend._set_pmt(settings)) + self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - self.assertEqual(self.backend._get_pmt_commands(settings), []) + asyncio.run(self.backend._set_pmt(settings)) + self.backend.send_command.assert_not_called() - def test_get_filter_commands(self): + def test_set_filter(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -253,16 +294,21 @@ def test_get_filter_commands(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual( - self.backend._get_filter_commands(settings), ["!AUTOFILTER OFF", "!EMFILTER 8 9"] + asyncio.run(self.backend._set_filter(settings)) + self.backend.send_command.assert_has_calls( + [call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")] ) + self.backend.send_command.reset_mock() settings.cutoff_filters = [] - self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER ON"]) + asyncio.run(self.backend._set_filter(settings)) + self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] - self.assertEqual(self.backend._get_filter_commands(settings), ["!AUTOFILTER ON"]) + asyncio.run(self.backend._set_filter(settings)) + self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") - def test_get_calibrate_command(self): + def test_set_calibrate(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -275,11 +321,14 @@ def test_get_calibrate_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_calibrate_command(settings), "!CALIBRATE ON") + asyncio.run(self.backend._set_calibrate(settings)) + self.backend.send_command.assert_called_once_with("!CALIBRATE ON") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU - self.assertEqual(self.backend._get_calibrate_command(settings), "!PMTCAL ON") + asyncio.run(self.backend._set_calibrate(settings)) + self.backend.send_command.assert_called_once_with("!PMTCAL ON") - def test_get_order_command(self): + def test_set_order(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -292,11 +341,14 @@ def test_get_order_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_order_command(settings), "!ORDER COLUMN") + asyncio.run(self.backend._set_order(settings)) + self.backend.send_command.assert_called_once_with("!ORDER COLUMN") + self.backend.send_command.reset_mock() settings.read_order = ReadOrder.WAVELENGTH - self.assertEqual(self.backend._get_order_command(settings), "!ORDER WAVELENGTH") + asyncio.run(self.backend._set_order(settings)) + self.backend.send_command.assert_called_once_with("!ORDER WAVELENGTH") - def test_get_speed_command(self): + def test_set_speed(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -309,13 +361,18 @@ def test_get_speed_command(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual(self.backend._get_speed_command(settings), "!SPEED ON") + asyncio.run(self.backend._set_speed(settings)) + self.backend.send_command.assert_called_once_with("!SPEED ON") + self.backend.send_command.reset_mock() settings.speed_read = False - self.assertEqual(self.backend._get_speed_command(settings), "!SPEED OFF") + asyncio.run(self.backend._set_speed(settings)) + self.backend.send_command.assert_called_once_with("!SPEED OFF") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU - self.assertIsNone(self.backend._get_speed_command(settings)) + asyncio.run(self.backend._set_speed(settings)) + self.backend.send_command.assert_not_called() - def test_get_integration_time_commands(self): + def test_set_integration_time(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.TIME, @@ -328,27 +385,16 @@ def test_get_integration_time_commands(self): kinetic_settings=None, spectrum_settings=None, ) - self.assertEqual( - self.backend._get_integration_time_commands(settings, 10, 100), - ["!COUNTTIMEDELAY 10", "!COUNTTIME 0.1"], + asyncio.run(self.backend._set_integration_time(settings, 10, 100)) + self.backend.send_command.assert_has_calls( + [call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")] ) + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - self.assertEqual(self.backend._get_integration_time_commands(settings, 10, 100), []) - - -class Test_get_nvram_and_tag_commands(unittest.TestCase): - def setUp(self): - self.mock_serial = MagicMock() - self.mock_serial.setup = AsyncMock() - self.mock_serial.stop = AsyncMock() - self.mock_serial.write = AsyncMock() - self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") - - with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): - self.backend = MolecularDevicesBackend(port="COM1") - self.backend.io = self.mock_serial + asyncio.run(self.backend._set_integration_time(settings, 10, 100)) + self.backend.send_command.assert_not_called() - def test_get_nvram_commands_polar(self): + def test_set_nvram_polar(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.POLAR, @@ -361,13 +407,11 @@ def test_get_nvram_commands_polar(self): kinetic_settings=None, spectrum_settings=None, settling_time=5, - is_settling_time_on=True, ) - self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM FPSETTLETIME 5"]) - settings.is_settling_time_on = False - self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM FPSETTLETIME 0"]) + asyncio.run(self.backend._set_nvram(settings)) + self.backend.send_command.assert_called_once_with("!NVRAM FPSETTLETIME 5") - def test_get_nvram_commands_other(self): + def test_set_nvram_other(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -380,13 +424,15 @@ def test_get_nvram_commands_other(self): kinetic_settings=None, spectrum_settings=None, settling_time=10, - is_settling_time_on=True, ) - self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM CARCOL 10"]) - settings.is_settling_time_on = False - self.assertEqual(self.backend._get_nvram_commands(settings), ["!NVRAM CARCOL 100"]) - - def test_get_tag_command(self): + asyncio.run(self.backend._set_nvram(settings)) + self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 100") + self.backend.send_command.reset_mock() + settings.settling_time = 110 + asyncio.run(self.backend._set_nvram(settings)) + self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 110") + + def test_set_tag(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.POLAR, @@ -399,12 +445,17 @@ def test_get_tag_command(self): kinetic_settings=KineticSettings(interval=10, num_readings=5), spectrum_settings=None, ) - self.assertEqual(self.backend._get_tag_command(settings), "!TAG ON") + asyncio.run(self.backend._set_tag(settings)) + self.backend.send_command.assert_called_once_with("!TAG ON") + self.backend.send_command.reset_mock() settings.read_type = ReadType.ENDPOINT - self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") + asyncio.run(self.backend._set_tag(settings)) + self.backend.send_command.assert_called_once_with("!TAG OFF") + self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS settings.read_type = ReadType.KINETIC - self.assertEqual(self.backend._get_tag_command(settings), "!TAG OFF") + asyncio.run(self.backend._set_tag(settings)) + self.backend.send_command.assert_called_once_with("!TAG OFF") @patch( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", @@ -419,17 +470,11 @@ def test_get_tag_command(self): "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - @patch( - "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", - new_callable=AsyncMock, - ) - def test_read_absorbance( - self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle - ): + def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_absorbance(plate, [500])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] + + commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) @@ -439,7 +484,12 @@ def test_read_absorbance( self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) self.assertIn("!SPEED OFF", commands) - self.assertIn(("!READTYPE ABSPLA", 2), commands) + + readtype_call = next( + c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE ABSPLA" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) + mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() @@ -457,20 +507,14 @@ def test_read_absorbance( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - @patch( - "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", - new_callable=AsyncMock, - ) - def test_read_fluorescence( - self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle - ): + def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] + + commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) self.assertIn("!SHAKE OFF", commands) @@ -483,8 +527,13 @@ def test_read_fluorescence( self.assertIn("!PMTCAL ONCE", commands) self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) - self.assertIn(("!READTYPE FLU", 1), commands) self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE FLU" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() @@ -502,20 +551,14 @@ def test_read_fluorescence( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - @patch( - "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", - new_callable=AsyncMock, - ) - def test_read_luminescence( - self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle - ): + def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_luminescence(plate, [590])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] + + commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) self.assertIn("!SHAKE OFF", commands) @@ -523,8 +566,13 @@ def test_read_luminescence( self.assertIn("!PMTCAL ONCE", commands) self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) - self.assertIn(("!READTYPE LUM", 1), commands) self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE LUM" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() @@ -542,20 +590,16 @@ def test_read_luminescence( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - @patch( - "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", - new_callable=AsyncMock, - ) def test_read_fluorescence_polarization( - self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + self, mock_read_now, mock_transfer_data, mock_wait_for_idle ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run(self.backend.read_fluorescence_polarization(plate, [485], [520], [515])) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] + + commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) self.assertIn("!SHAKE OFF", commands) @@ -568,8 +612,13 @@ def test_read_fluorescence_polarization( self.assertIn("!PMTCAL ONCE", commands) self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) - self.assertIn(("!READTYPE POLAR", 1), commands) self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE POLAR" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() @@ -587,12 +636,8 @@ def test_read_fluorescence_polarization( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - @patch( - "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._send_commands", - new_callable=AsyncMock, - ) def test_read_time_resolved_fluorescence( - self, mock_send_commands, mock_read_now, mock_transfer_data, mock_wait_for_idle + self, mock_read_now, mock_transfer_data, mock_wait_for_idle ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") asyncio.run( @@ -600,11 +645,11 @@ def test_read_time_resolved_fluorescence( plate, [485], [520], [515], delay_time=10, integration_time=100 ) ) - mock_send_commands.assert_called_once() - commands = mock_send_commands.call_args[0][0] + + commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(isinstance(cmd, str) and cmd.startswith("!YPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) self.assertIn("!SHAKE OFF", commands) @@ -619,8 +664,15 @@ def test_read_time_resolved_fluorescence( self.assertIn("!ORDER COLUMN", commands) self.assertIn("!COUNTTIMEDELAY 10", commands) self.assertIn("!COUNTTIME 0.1", commands) - self.assertIn(("!READTYPE TIME 0 250", 1), commands) self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c + for c in self.backend.send_command.call_args_list + if c.args[0] == "!READTYPE TIME 0 250" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + mock_read_now.assert_called_once() mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index 311fa7ea13f..b44afd8d447 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import List from .molecular_devices_backend import MolecularDevicesBackend, MolecularDevicesSettings @@ -6,18 +6,18 @@ class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): """Backend for Molecular Devices SpectraMax 384 Plus plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b">") -> None: - super().__init__(port, res_term_char) + def __init__(self, port: str) -> None: + super().__init__(port) - def _get_readtype_command(self, settings: MolecularDevicesSettings) -> Tuple[str, int]: - """Get the READTYPE command and the expected number of response fields.""" + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + """Set the READTYPE command and the expected number of response fields.""" cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" - return (cmd, 1) + await self.send_command(cmd, num_res_fields=1) - def _get_nvram_commands(self, settings): - return [None] + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: + pass - def _get_tag_command(self, settings): + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: pass async def read_fluorescence(self, *args, **kwargs) -> List[List[float]]: diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py index f19ed1448f7..f781b3b4640 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_m5_backend.py @@ -4,5 +4,5 @@ class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): """Backend for Molecular Devices SpectraMax M5 plate readers.""" - def __init__(self, port: str, res_term_char: bytes = b">") -> None: - super().__init__(port, res_term_char) + def __init__(self, port: str) -> None: + super().__init__(port) From 50d7178f2f5883432ba76b55c04804f20d0adc7d Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Thu, 16 Oct 2025 20:41:21 -0700 Subject: [PATCH 11/18] reformat --- .../plate_reading/molecular_devices_backend.py | 10 ++++------ .../plate_reading/molecular_devices_backend_tests.py | 12 +++--------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 051f9ebaec2..f0223c8ebd6 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -13,7 +13,7 @@ logger = logging.getLogger("pylabrobot") -RES_TERM_CHAR = b'>' +RES_TERM_CHAR = b">" COMMAND_TERMINATORS: Dict[str, int] = { "!AUTOFILTER": 1, "!AUTOPMT": 1, @@ -246,7 +246,6 @@ class MolecularDevicesSettings: settling_time: int = 0 - @dataclass class MolecularDevicesData: """Data from a Molecular Devices plate reader.""" @@ -320,7 +319,6 @@ def __init__(self, port: str) -> None: self.port = port self.io = Serial(self.port, baudrate=9600, timeout=0.2) - async def setup(self) -> None: await self.io.setup() await self.send_command("!") @@ -374,8 +372,8 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: message, err_class = ERROR_CODES[error_code] raise err_class(f"Command '{command}' failed with error {error_code}: {message}") raise MolecularDevicesError( - f"Command '{command}' failed with unknown error code: {error_code}" - ) + f"Command '{command}' failed with unknown error code: {error_code}" + ) except (ValueError, IndexError): raise MolecularDevicesError( f"Command '{command}' failed with unparsable error: {response[0]}" @@ -1014,7 +1012,7 @@ async def read_fluorescence_polarization( cutoff_filters=cutoff_filters, cuvette=cuvette, speed_read=False, - settling_time=settling_time + settling_time=settling_time, ) await self._set_clear() if not cuvette: diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 8feb8665613..d0dea31f59d 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -295,9 +295,7 @@ def test_set_filter(self): spectrum_settings=None, ) asyncio.run(self.backend._set_filter(settings)) - self.backend.send_command.assert_has_calls( - [call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")] - ) + self.backend.send_command.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) self.backend.send_command.reset_mock() settings.cutoff_filters = [] asyncio.run(self.backend._set_filter(settings)) @@ -386,9 +384,7 @@ def test_set_integration_time(self): spectrum_settings=None, ) asyncio.run(self.backend._set_integration_time(settings, 10, 100)) - self.backend.send_command.assert_has_calls( - [call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")] - ) + self.backend.send_command.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS asyncio.run(self.backend._set_integration_time(settings, 10, 100)) @@ -667,9 +663,7 @@ def test_read_time_resolved_fluorescence( self.assertIn("!READSTAGE TOP", commands) readtype_call = next( - c - for c in self.backend.send_command.call_args_list - if c.args[0] == "!READTYPE TIME 0 250" + c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE TIME 0 250" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) From ed7569ca9c9d033c576120b9cd09244f4c7f128c Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Thu, 16 Oct 2025 21:23:53 -0700 Subject: [PATCH 12/18] refactor, minor fixes --- .../molecular_devices_backend.py | 11 +- .../molecular_devices_backend_tests.py | 196 +++++++++--------- 2 files changed, 107 insertions(+), 100 deletions(-) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index f0223c8ebd6..785b8eb7ab0 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -412,7 +412,7 @@ async def set_temperature(self, temperature: float) -> None: await self.send_command(f"!TEMP {temperature}") async def get_firmware_version(self) -> str: - await self.send_command("!OPTION") + return await self.send_command("!OPTION") async def start_shake(self) -> None: await self.send_command("!SHAKE NOW") @@ -619,14 +619,16 @@ async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: if settings.path_check: wl_str += " 900 998" await self.send_command(f"!WAVELENGTH {wl_str}") - if settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): + elif settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) em_wl_str = " ".join(map(str, settings.emission_wavelengths)) await self.send_command(f"!EXWAVELENGTH {ex_wl_str}") await self.send_command(f"!EMWAVELENGTH {em_wl_str}") - if settings.read_mode == ReadMode.LUM: + elif settings.read_mode == ReadMode.LUM: wl_str = " ".join(map(str, settings.emission_wavelengths)) await self.send_command(f"!EMWAVELENGTH {wl_str}") + else: + raise NotImplementedError("f{settings.read_mode} not supported") async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: plate = settings.plate @@ -1057,6 +1059,7 @@ async def read_time_resolved_fluorescence( spectrum_settings: Optional[SpectrumSettings] = None, cuvette: bool = False, settling_time: int = 0, + timeout: int = 600, ) -> MolecularDevicesDataCollection: settings = MolecularDevicesSettings( plate=plate, @@ -1100,5 +1103,5 @@ async def read_time_resolved_fluorescence( await self._set_nvram(settings) await self._read_now() - await self._wait_for_idle() + await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index d0dea31f59d..9f156cb0089 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -27,7 +27,7 @@ from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul -class TestMolecularDevicesBackend(unittest.TestCase): +class TestMolecularDevicesBackend(unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock_serial = MagicMock() self.mock_serial.setup = AsyncMock() @@ -40,20 +40,20 @@ def setUp(self): self.backend.io = self.mock_serial self.backend.send_command = AsyncMock() - def test_setup_stop(self): + async def test_setup_stop(self): # un-mock send_command for this test self.backend.send_command = AsyncMock(wraps=self.backend.send_command) - asyncio.run(self.backend.setup()) + await self.backend.setup() self.mock_serial.setup.assert_called_once() self.backend.send_command.assert_called_with("!") - asyncio.run(self.backend.stop()) + await self.backend.stop() self.mock_serial.stop.assert_called_once() - def test_set_clear(self): - asyncio.run(self.backend._set_clear()) + async def test_set_clear(self): + await self.backend._set_clear() self.backend.send_command.assert_called_once_with("!CLEAR DATA") - def test_set_mode(self): + async def test_set_mode(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -66,27 +66,27 @@ def test_set_mode(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_mode(settings)) + await self.backend._set_mode(settings) self.backend.send_command.assert_called_once_with("!MODE ENDPOINT") self.backend.send_command.reset_mock() settings.read_type = ReadType.KINETIC settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - asyncio.run(self.backend._set_mode(settings)) + await self.backend._set_mode(settings) self.backend.send_command.assert_called_once_with("!MODE KINETIC 10 5") self.backend.send_command.reset_mock() settings.read_type = ReadType.SPECTRUM settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) - asyncio.run(self.backend._set_mode(settings)) + await self.backend._set_mode(settings) self.backend.send_command.assert_called_once_with("!MODE SPECTRUM 200 10 50") self.backend.send_command.reset_mock() settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" - asyncio.run(self.backend._set_mode(settings)) + await self.backend._set_mode(settings) self.backend.send_command.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") - def test_set_wavelengths(self): + async def test_set_wavelengths(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -100,19 +100,19 @@ def test_set_wavelengths(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_wavelengths(settings)) + await self.backend._set_wavelengths(settings) self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600") self.backend.send_command.reset_mock() settings.path_check = True - asyncio.run(self.backend._set_wavelengths(settings)) + await self.backend._set_wavelengths(settings) self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600 900 998") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] - asyncio.run(self.backend._set_wavelengths(settings)) + await self.backend._set_wavelengths(settings) self.backend.send_command.assert_has_calls( [call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")] ) @@ -120,10 +120,10 @@ def test_set_wavelengths(self): self.backend.send_command.reset_mock() settings.read_mode = ReadMode.LUM settings.emission_wavelengths = [590] - asyncio.run(self.backend._set_wavelengths(settings)) + await self.backend._set_wavelengths(settings) self.backend.send_command.assert_called_once_with("!EMWAVELENGTH 590") - def test_set_plate_position(self): + async def test_set_plate_position(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( plate=plate, @@ -137,12 +137,12 @@ def test_set_plate_position(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_plate_position(settings)) + await self.backend._set_plate_position(settings) self.backend.send_command.assert_has_calls( [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] ) - def test_set_strip(self): + async def test_set_strip(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") settings = MolecularDevicesSettings( plate=plate, @@ -156,10 +156,10 @@ def test_set_strip(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_strip(settings)) + await self.backend._set_strip(settings) self.backend.send_command.assert_called_once_with("!STRIP 1 12") - def test_set_shake(self): + async def test_set_shake(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -172,21 +172,21 @@ def test_set_shake(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_shake(settings)) + await self.backend._set_shake(settings) self.backend.send_command.assert_called_once_with("!SHAKE OFF") self.backend.send_command.reset_mock() settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) - asyncio.run(self.backend._set_shake(settings)) + await self.backend._set_shake(settings) self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) self.backend.send_command.reset_mock() settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - asyncio.run(self.backend._set_shake(settings)) + await self.backend._set_shake(settings) self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) - def test_set_carriage_speed(self): + async def test_set_carriage_speed(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -199,14 +199,14 @@ def test_set_carriage_speed(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_carriage_speed(settings)) + await self.backend._set_carriage_speed(settings) self.backend.send_command.assert_called_once_with("!CSPEED 8") self.backend.send_command.reset_mock() settings.carriage_speed = CarriageSpeed.SLOW - asyncio.run(self.backend._set_carriage_speed(settings)) + await self.backend._set_carriage_speed(settings) self.backend.send_command.assert_called_once_with("!CSPEED 1") - def test_set_read_stage(self): + async def test_set_read_stage(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -219,18 +219,18 @@ def test_set_read_stage(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_read_stage(settings)) + await self.backend._set_read_stage(settings) self.backend.send_command.assert_called_once_with("!READSTAGE TOP") self.backend.send_command.reset_mock() settings.read_from_bottom = True - asyncio.run(self.backend._set_read_stage(settings)) + await self.backend._set_read_stage(settings) self.backend.send_command.assert_called_once_with("!READSTAGE BOT") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - asyncio.run(self.backend._set_read_stage(settings)) + await self.backend._set_read_stage(settings) self.backend.send_command.assert_not_called() - def test_set_flashes_per_well(self): + async def test_set_flashes_per_well(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -244,14 +244,14 @@ def test_set_flashes_per_well(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_flashes_per_well(settings)) + await self.backend._set_flashes_per_well(settings) self.backend.send_command.assert_called_once_with("!FPW 10") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - asyncio.run(self.backend._set_flashes_per_well(settings)) + await self.backend._set_flashes_per_well(settings) self.backend.send_command.assert_not_called() - def test_set_pmt(self): + async def test_set_pmt(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -265,22 +265,22 @@ def test_set_pmt(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_pmt(settings)) + await self.backend._set_pmt(settings) self.backend.send_command.assert_called_once_with("!AUTOPMT ON") self.backend.send_command.reset_mock() settings.pmt_gain = PmtGain.HIGH - asyncio.run(self.backend._set_pmt(settings)) + await self.backend._set_pmt(settings) self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) self.backend.send_command.reset_mock() settings.pmt_gain = 9 - asyncio.run(self.backend._set_pmt(settings)) + await self.backend._set_pmt(settings) self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - asyncio.run(self.backend._set_pmt(settings)) + await self.backend._set_pmt(settings) self.backend.send_command.assert_not_called() - def test_set_filter(self): + async def test_set_filter(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.FLU, @@ -294,19 +294,19 @@ def test_set_filter(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_filter(settings)) + await self.backend._set_filter(settings) self.backend.send_command.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) self.backend.send_command.reset_mock() settings.cutoff_filters = [] - asyncio.run(self.backend._set_filter(settings)) + await self.backend._set_filter(settings) self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] - asyncio.run(self.backend._set_filter(settings)) + await self.backend._set_filter(settings) self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") - def test_set_calibrate(self): + async def test_set_calibrate(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -319,14 +319,14 @@ def test_set_calibrate(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_calibrate(settings)) + await self.backend._set_calibrate(settings) self.backend.send_command.assert_called_once_with("!CALIBRATE ON") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU - asyncio.run(self.backend._set_calibrate(settings)) + await self.backend._set_calibrate(settings) self.backend.send_command.assert_called_once_with("!PMTCAL ON") - def test_set_order(self): + async def test_set_order(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -339,14 +339,14 @@ def test_set_order(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_order(settings)) + await self.backend._set_order(settings) self.backend.send_command.assert_called_once_with("!ORDER COLUMN") self.backend.send_command.reset_mock() settings.read_order = ReadOrder.WAVELENGTH - asyncio.run(self.backend._set_order(settings)) + await self.backend._set_order(settings) self.backend.send_command.assert_called_once_with("!ORDER WAVELENGTH") - def test_set_speed(self): + async def test_set_speed(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -359,18 +359,18 @@ def test_set_speed(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_speed(settings)) + await self.backend._set_speed(settings) self.backend.send_command.assert_called_once_with("!SPEED ON") self.backend.send_command.reset_mock() settings.speed_read = False - asyncio.run(self.backend._set_speed(settings)) + await self.backend._set_speed(settings) self.backend.send_command.assert_called_once_with("!SPEED OFF") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.FLU - asyncio.run(self.backend._set_speed(settings)) + await self.backend._set_speed(settings) self.backend.send_command.assert_not_called() - def test_set_integration_time(self): + async def test_set_integration_time(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.TIME, @@ -383,14 +383,14 @@ def test_set_integration_time(self): kinetic_settings=None, spectrum_settings=None, ) - asyncio.run(self.backend._set_integration_time(settings, 10, 100)) + await self.backend._set_integration_time(settings, 10, 100) self.backend.send_command.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS - asyncio.run(self.backend._set_integration_time(settings, 10, 100)) + await self.backend._set_integration_time(settings, 10, 100) self.backend.send_command.assert_not_called() - def test_set_nvram_polar(self): + async def test_set_nvram_polar(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.POLAR, @@ -404,10 +404,10 @@ def test_set_nvram_polar(self): spectrum_settings=None, settling_time=5, ) - asyncio.run(self.backend._set_nvram(settings)) + await self.backend._set_nvram(settings) self.backend.send_command.assert_called_once_with("!NVRAM FPSETTLETIME 5") - def test_set_nvram_other(self): + async def test_set_nvram_other(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.ABS, @@ -421,14 +421,14 @@ def test_set_nvram_other(self): spectrum_settings=None, settling_time=10, ) - asyncio.run(self.backend._set_nvram(settings)) + await self.backend._set_nvram(settings) self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 100") self.backend.send_command.reset_mock() settings.settling_time = 110 - asyncio.run(self.backend._set_nvram(settings)) + await self.backend._set_nvram(settings) self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 110") - def test_set_tag(self): + async def test_set_tag(self): settings = MolecularDevicesSettings( plate=MagicMock(), read_mode=ReadMode.POLAR, @@ -441,16 +441,16 @@ def test_set_tag(self): kinetic_settings=KineticSettings(interval=10, num_readings=5), spectrum_settings=None, ) - asyncio.run(self.backend._set_tag(settings)) + await self.backend._set_tag(settings) self.backend.send_command.assert_called_once_with("!TAG ON") self.backend.send_command.reset_mock() settings.read_type = ReadType.ENDPOINT - asyncio.run(self.backend._set_tag(settings)) + await self.backend._set_tag(settings) self.backend.send_command.assert_called_once_with("!TAG OFF") self.backend.send_command.reset_mock() settings.read_mode = ReadMode.ABS settings.read_type = ReadType.KINETIC - asyncio.run(self.backend._set_tag(settings)) + await self.backend._set_tag(settings) self.backend.send_command.assert_called_once_with("!TAG OFF") @patch( @@ -466,9 +466,9 @@ def test_set_tag(self): "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_absorbance(plate, [500])) + await self.backend.read_absorbance(plate, [500]) commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -503,9 +503,9 @@ def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_ "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_fluorescence(plate, [485], [520], [515])) + await self.backend.read_fluorescence(plate, [485], [520], [515]) commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -547,9 +547,9 @@ def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_fo "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_luminescence(plate, [590])) + await self.backend.read_luminescence(plate, [590]) commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -586,11 +586,14 @@ def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_fo "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - def test_read_fluorescence_polarization( - self, mock_read_now, mock_transfer_data, mock_wait_for_idle + async def test_read_fluorescence_polarization( + self, + mock_read_now, + mock_transfer_data, + mock_wait_for_idle, ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run(self.backend.read_fluorescence_polarization(plate, [485], [520], [515])) + await self.backend.read_fluorescence_polarization(plate, [485], [520], [515]) commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -632,15 +635,16 @@ def test_read_fluorescence_polarization( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) - def test_read_time_resolved_fluorescence( - self, mock_read_now, mock_transfer_data, mock_wait_for_idle + async def test_read_time_resolved_fluorescence( + self, + mock_read_now, + mock_transfer_data, + mock_wait_for_idle, ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - asyncio.run( - self.backend.read_time_resolved_fluorescence( + await self.backend.read_time_resolved_fluorescence( plate, [485], [520], [515], delay_time=10, integration_time=100 ) - ) commands = [c.args[0] for c in self.backend.send_command.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -672,7 +676,7 @@ def test_read_time_resolved_fluorescence( mock_transfer_data.assert_called_once() -class TestDataParsing(unittest.TestCase): +class TestDataParsing(unittest.IsolatedAsyncioTestCase): def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): self.backend = MolecularDevicesBackend(port="COM1") @@ -769,7 +773,7 @@ def test_parse_data_with_sat_and_nan(self): self.assertEqual(read.data[1][0], float("inf")) self.assertTrue(math.isnan(read.data[1][1])) - def test_parse_kinetic_absorbance(self): + async def test_parse_kinetic_absorbance(self): # Mock the send_command to return two different data blocks def data_generator(): yield [ @@ -808,7 +812,7 @@ def data_generator(): spectrum_settings=None, ) - result = asyncio.run(self.backend._transfer_data(settings)) + result = await self.backend._transfer_data(settings) self.assertEqual(len(result.reads), 2) self.assertEqual(result.reads[0].data, [[0.1, 0.3], [0.2, 0.4]]) self.assertEqual(result.reads[1].data, [[0.15, 0.35], [0.25, 0.45]]) @@ -816,7 +820,7 @@ def data_generator(): self.assertEqual(result.reads[1].measurement_time, 12355.6) -class TestErrorHandling(unittest.TestCase): +class TestErrorHandling(unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock_serial = MagicMock() self.mock_serial.setup = AsyncMock() @@ -832,53 +836,53 @@ async def _mock_send_command_response(self, response_str: str): self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] return await self.backend.send_command("!TEST") - def test_parse_basic_errors_fail_known_error_code(self): + async def test_parse_basic_errors_fail_known_error_code(self): # Test a known error code (e.g., 107: no data to transfer) with self.assertRaisesRegex( MolecularDevicesUnrecognizedCommandError, "Command '!TEST' failed with error 107: no data to transfer", ): - asyncio.run(self._mock_send_command_response("OK\t\r\n>FAIL\t 107")) + await self._mock_send_command_response("OK\t\r\n>FAIL\t 107") - def test_parse_basic_errors_fail_unknown_error_code(self): + async def test_parse_basic_errors_fail_unknown_error_code(self): # Test an unknown error code with self.assertRaisesRegex( MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999" ): - asyncio.run(self._mock_send_command_response("FAIL\t 999")) + await self._mock_send_command_response("FAIL\t 999") - def test_parse_basic_errors_fail_unparsable_error(self): + async def test_parse_basic_errors_fail_unparsable_error(self): # Test an unparsable error message (e.g., not an integer code) with self.assertRaisesRegex( MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC" ): - asyncio.run(self._mock_send_command_response("FAIL\t ABC")) + await self._mock_send_command_response("FAIL\t ABC") - def test_parse_basic_errors_empty_response(self): + async def test_parse_basic_errors_empty_response(self): # Test an empty response from the device self.mock_serial.readline.return_value = b"" # Simulate no response with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): - asyncio.run(self.backend.send_command("!TEST", timeout=0.01)) # Short timeout for test + await self.backend.send_command("!TEST", timeout=0.01) # Short timeout for test - def test_parse_basic_errors_warning_response(self): + async def test_parse_basic_errors_warning_response(self): # Test a response containing a warning self.mock_serial.readline.side_effect = [b"OK\tWarning: Something happened>\r\n"] # Expect no exception, but a warning logged (not directly testable with assertRaises) # We can assert that no error is raised. try: - asyncio.run(self.backend.send_command("!TEST")) + await self.backend.send_command("!TEST") except MolecularDevicesError: self.fail("MolecularDevicesError raised for a warning response") - def test_parse_basic_errors_ok_response(self): + async def test_parse_basic_errors_ok_response(self): # Test a normal OK response self.mock_serial.readline.side_effect = [b"OK>\r\n"] try: - response = asyncio.run(self.backend.send_command("!TEST")) + response = await self.backend.send_command("!TEST") self.assertEqual(response, ["OK"]) except MolecularDevicesError: self.fail("MolecularDevicesError raised for a valid OK response") if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 5d8d00ba5b04140d5335a65cab7288a7ebaf67f2 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 22 Oct 2025 18:49:59 -0700 Subject: [PATCH 13/18] minor fix --- docs/user_guide/machines.md | 5 ++++- pylabrobot/plate_reading/molecular_devices_backend_tests.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index f1dc03a33da..b9fc5c63b18 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -33,6 +33,8 @@ tr > td:nth-child(5) { width: 15%; } .badge-absorbance { background: #ffe6cc; } .badge-fluorescence { background: #e0ffcc; } .badge-luminescence { background: #e6e6ff; } +.badge-time-resolved-fluo { background: #ddffdd; } +.badge-fluo-polarization { background: #ddf2ffff; } ``` @@ -149,7 +151,8 @@ tr > td:nth-child(5) { width: 15%; } | Agilent (BioTek) | Cytation 5 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/cytation5.html) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) | | Byonoy | Absorbance 96 Automate | absorbance | WIP | [OEM](https://byonoy.com/absorbance-96-automate/) | | Byonoy | Luminescence 96 Automate | luminescence | WIP | [OEM](https://byonoy.com/luminescence-96-automate/) | - +| Molecular Devices | SpectraMax M5e | absorbancefluorescence time-resolved fluorescencefluorescence polarization | Full | [OEM] (https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) | +| Molecular Devices | SpectraMax 384plus | absorbance | Full | [OEM] (https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) | ### Flow Cytometers | Manufacturer | Machine | PLR-Support | Links | diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 9f156cb0089..c6bd1259739 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -1,4 +1,3 @@ -import asyncio import math import unittest from unittest.mock import AsyncMock, MagicMock, call, patch From d274c60d043ba8fd454d2da9d4f1d264ca439f6c Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Tue, 4 Nov 2025 21:38:47 -0800 Subject: [PATCH 14/18] update read methods output format --- pylabrobot/plate_reading/backend.py | 33 ++- pylabrobot/plate_reading/biotek_backend.py | 108 ++++++++-- pylabrobot/plate_reading/biotek_tests.py | 36 +++- pylabrobot/plate_reading/chatterbox.py | 26 ++- .../plate_reading/clario_star_backend.py | 49 +++-- .../molecular_devices_backend.py | 142 ++++-------- .../molecular_devices_backend_tests.py | 202 +++++++++++++----- 7 files changed, 394 insertions(+), 202 deletions(-) diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/plate_reading/backend.py index 95b4c39965a..3c08f5812e5 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/plate_reading/backend.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import List, Optional +from typing import Dict, List, Optional, Tuple from pylabrobot.machines.backend import MachineBackend from pylabrobot.plate_reading.standard import ( @@ -39,16 +39,24 @@ async def close(self, plate: Optional[Plate]) -> None: @abstractmethod async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[List[Optional[float]]]: - """Read the luminescence from the plate reader. This should return a list of lists, where the - outer list is the columns of the plate and the inner list is the rows of the plate.""" + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the luminescence from the plate reader. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key (0, 0) + and a value containing the data, temperature, and time. + """ @abstractmethod async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[List[Optional[float]]]: - """Read the absorbance from the plate reader. This should return a list of lists, where the - outer list is the columns of the plate and the inner list is the rows of the plate.""" + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the absorbance from the plate reader. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key (wavelength, 0) + and a value containing the data, temperature, and time. + """ @abstractmethod async def read_fluorescence( @@ -58,9 +66,14 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[List[Optional[float]]]: - """Read the fluorescence from the plate reader. This should return a list of lists, where the - outer list is the columns of the plate and the inner list is the rows of the plate.""" + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the fluorescence from the plate reader. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key + (excitation_wavelength, emission_wavelength) and a value containing the data, temperature, + and time. + """ class ImagerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py index 13b2d9852be..b33d9a6f492 100644 --- a/pylabrobot/plate_reading/biotek_backend.py +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -612,13 +612,14 @@ async def set_temperature(self, temperature: float): async def stop_heating_or_cooling(self): return await self.send_command("g", "00000") - def _parse_body(self, body: bytes) -> Dict[Tuple[int, int], float]: + def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: + assert self._plate is not None, "Plate must be set before reading data" + plate=self._plate start_index = 22 end_index = body.rindex(b"\r\n") - num_rows = 8 + num_rows = plate.num_items_y rows = body[start_index:end_index].split(b"\r\n,")[:num_rows] - assert self._plate is not None, "Plate must be set before reading data" parsed_data: Dict[Tuple[int, int], float] = {} for row in rows: values = row.split(b",") @@ -631,16 +632,11 @@ def _parse_body(self, body: bytes) -> Dict[Tuple[int, int], float]: value = float(group[2].decode()) parsed_data[(row_index, column_index)] = value - return parsed_data - - def _data_dict_to_list( - self, data: Dict[Tuple[int, int], float], plate: Plate - ) -> List[List[Optional[float]]]: result: List[List[Optional[float]]] = [ [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) ] - for (row, col), value in data.items(): - result[row][col] = value + for (row, col), value in parsed_data.items(): + result[row][col] = value return result async def set_plate(self, plate: Plate): @@ -707,14 +703,16 @@ def _get_min_max_row_col_tuples( async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: if not 230 <= wavelength <= 999: raise ValueError("Wavelength must be between 230 and 999") await self.set_plate(plate) wavelength_str = str(wavelength).zfill(4) - data: Dict[Tuple[int, int], float] = {} + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): cmd = f"004701{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000010600008{wavelength_str}1" @@ -727,13 +725,33 @@ async def read_absorbance( # read data body = await self._read_until(b"\x03", timeout=60 * 3) - assert resp is not None - data.update(self._parse_body(body)) - return self._data_dict_to_list(data, plate) + assert body is not None + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + (wavelength, 0): { + "data": all_data, + "temp": temp, + "time": time.time(), + } + } + ] async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: if not 4.5 <= focal_height <= 13.88: raise ValueError("Focal height must be between 4.5 and 13.88") @@ -753,7 +771,9 @@ async def read_luminescence( integration_time_seconds_s = str(integration_time_seconds * 5).zfill(2) integration_time_milliseconds_s = str(int(float(integration_time_milliseconds * 50))).zfill(2) - data: Dict[Tuple[int, int], float] = {} + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): cmd = f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" # 0812 checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8 @@ -768,8 +788,28 @@ async def read_luminescence( timeout = 60 + integration_time_seconds * (2 * 60 + 10) body = await self._read_until(b"\x03", timeout=timeout) assert body is not None - data.update(self._parse_body(body)) - return self._data_dict_to_list(data, plate) + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + (0, 0): { # Luminescence does not have excitation/emission wavelength in the same way + "data": all_data, + "temp": temp, + "time": time.time(), + } + } + ] async def read_fluorescence( self, @@ -778,7 +818,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: if not 4.5 <= focal_height <= 13.88: raise ValueError("Focal height must be between 4.5 and 13.88") if not 250 <= excitation_wavelength <= 700: @@ -794,7 +834,9 @@ async def read_fluorescence( excitation_wavelength_str = str(excitation_wavelength).zfill(4) emission_wavelength_str = str(emission_wavelength).zfill(4) - data: Dict[Tuple[int, int], float] = {} + all_data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): cmd = ( f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000" @@ -809,8 +851,28 @@ async def read_fluorescence( body = await self._read_until(b"\x03", timeout=60 * 2) assert body is not None - data.update(self._parse_body(body)) - return self._data_dict_to_list(data, plate) + parsed_data = self._parse_body(body) + # Merge data + for r in range(plate.num_items_y): + for c in range(plate.num_items_x): + if parsed_data[r][c] is not None: + all_data[r][c] = parsed_data[r][c] + + # Get current temperature + try: + temp = await self.get_current_temperature() + except TimeoutError: + temp = float("nan") + + return [ + { + (excitation_wavelength, emission_wavelength): { + "data": all_data, + "temp": temp, + "time": time.time(), + } + } + ] async def _abort(self) -> None: await self.send_command("x", wait_for_response=False) diff --git a/pylabrobot/plate_reading/biotek_tests.py b/pylabrobot/plate_reading/biotek_tests.py index 51970e25fec..e8ae3a7b6ba 100644 --- a/pylabrobot/plate_reading/biotek_tests.py +++ b/pylabrobot/plate_reading/biotek_tests.py @@ -4,6 +4,7 @@ import unittest import unittest.mock from typing import Iterator +import time from pylabrobot.plate_reading.biotek_backend import Cytation5Backend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb @@ -34,6 +35,10 @@ async def asyncSetUp(self): self.backend.io.set_rts = unittest.mock.AsyncMock() self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate") + # Mock time.time() to control the timestamp in the results + self.mock_time = unittest.mock.patch("time.time", return_value=12345.6789).start() + self.addCleanup(self.mock_time.stop) + async def test_setup(self): self.backend.io.read.side_effect = _byte_iter("\x061650200 Version 1.04 0000\x03") await self.backend.setup() @@ -94,6 +99,7 @@ async def test_read_absorbance(self): "0.0670,08,05,+0.0732,08,04,+0.0657,08,03,+0.0684,08,02,+0.1174,08,01,+0.1427\r\n228\x1a0" "41\x1a0000\x03" ) + + "\x062360000\x03" # Temperature call ) plate = CellVis_96_wellplate_350uL_Fb(name="plate") @@ -107,7 +113,7 @@ async def test_read_absorbance(self): ) self.backend.io.write.assert_any_call(b"O") - assert resp == [ + expected_data = [ [ 0.1917, 0.1225, @@ -182,6 +188,13 @@ async def test_read_absorbance(self): [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], ] + self.assertEqual(resp, [{ + (580, 0): { + "data": expected_data, + "temp": 23.6, + "time": 12345.6789 + } + }]) async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( @@ -206,6 +219,7 @@ async def test_read_luminescence_partial(self): + "0350000000000000010000000000000170000\x03" + "\x060000\x03" + "01,1,\r000:00:00.0,237,04,04,0000018\r\n,05,04,0000017\r\n,06,04,0000014\r\n237\x1a210\x1a0000\x03" + + "\x062360000\x03" # Temperature call ) plate = CellVis_96_wellplate_350uL_Fb(name="plate") @@ -214,7 +228,6 @@ async def test_read_luminescence_partial(self): focal_height=4.5, integration_time=0.4, plate=plate, wells=wells ) - print(self.backend.io.write.mock_calls) self.backend.io.write.assert_any_call(b"D") self.backend.io.write.assert_any_call( b"008401010107010001200100001100100000123000020200200-001000-00300000000000000000001351086" @@ -227,7 +240,7 @@ async def test_read_luminescence_partial(self): ) self.backend.io.write.assert_any_call(b"O") - assert resp == [ + expected_data = [ [3.0, None, None, None, None, None, None, None, None, None, None, None], [3.0, 43.0, 14.0, None, None, None, None, None, None, None, None, None], [5.0, 12.0, 14.0, None, None, None, None, None, None, None, None, None], @@ -237,6 +250,13 @@ async def test_read_luminescence_partial(self): [0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None], [None, None, None, None, None, None, None, None, None, None, None, None], ] + self.assertEqual(resp, [{ + (0, 0): { + "data": expected_data, + "temp": 23.6, + "time": 12345.6789 + } + }]) async def test_read_fluorescence(self): self.backend.io.read.side_effect = _byte_iter( @@ -266,6 +286,7 @@ async def test_read_fluorescence(self): ",0000607,08,10,0003002,08,09,0000900,08,08,0000697,08,07,0000542,08,06,0000688,08,05,0000" "622,08,04,0000555,08,03,0000542,08,02,0000742,08,01,0001118\r\n228\x1a091\x1a0000\x03" ) + + "\x062360000\x03" # Temperature call ) plate = CellVis_96_wellplate_350uL_Fb(name="plate") @@ -286,7 +307,7 @@ async def test_read_fluorescence(self): ) self.backend.io.write.assert_any_call(b"O") - assert resp == [ + expected_data = [ [427.0, 746.0, 598.0, 742.0, 1516.0, 704.0, 676.0, 734.0, 1126.0, 790.0, 531.0, 531.0], [462.0, 2187.0, 501.0, 465.0, 576.0, 484.0, 731.0, 891.0, 629.0, 618.0, 541.0, 2066.0], [728.0, 583.0, 472.0, 492.0, 501.0, 491.0, 580.0, 541.0, 556.0, 474.0, 532.0, 522.0], @@ -296,3 +317,10 @@ async def test_read_fluorescence(self): [653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0], [1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0], ] + self.assertEqual(resp, [{ + (485, 528): { + "data": expected_data, + "temp": 23.6, + "time": 12345.6789 + } + }]) diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py index 1459d9c46b5..4a71251bd33 100644 --- a/pylabrobot/plate_reading/chatterbox.py +++ b/pylabrobot/plate_reading/chatterbox.py @@ -1,4 +1,5 @@ -from typing import List, Optional +from typing import Dict, List, Optional, Tuple +import time from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well @@ -70,21 +71,32 @@ def _mask_result( masked[r][c] = result[r][c] return masked + def _format_data(self, data: List[List[Optional[float]]], key: Tuple[int, int]) -> List[Dict[Tuple[int, int], Dict]]: + return [ + { + key: { + "data": data, + "temp": float("nan"), + "time": time.time(), + } + } + ] + async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: print(f"Reading luminescence at focal height {focal_height}.") result = self._mask_result(self.dummy_luminescence, wells, plate) self._print_plate_reading_wells(result) - return result + return self._format_data(result, (0, 0)) async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: print(f"Reading absorbance at wavelength {wavelength}.") result = self._mask_result(self.dummy_absorbance, wells, plate) self._print_plate_reading_wells(result) - return self.dummy_absorbance + return self._format_data(result, (wavelength, 0)) async def read_fluorescence( self, @@ -93,10 +105,10 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: print( f"Reading fluorescence at excitation wavelength {excitation_wavelength}, emission wavelength {emission_wavelength}, and focal height {focal_height}." ) result = self._mask_result(self.dummy_fluorescence, wells, plate) self._print_plate_reading_wells(result) - return result + return self._format_data(result, (excitation_wavelength, emission_wavelength)) diff --git a/pylabrobot/plate_reading/clario_star_backend.py b/pylabrobot/plate_reading/clario_star_backend.py index 8214462c54a..4caa75247e5 100644 --- a/pylabrobot/plate_reading/clario_star_backend.py +++ b/pylabrobot/plate_reading/clario_star_backend.py @@ -4,7 +4,7 @@ import struct import sys import time -from typing import List, Optional, Union +from typing import Dict, List, Optional, Tuple, Union from pylabrobot.resources.well import Well @@ -259,7 +259,7 @@ async def _get_measurement_values(self): async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float = 13 - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: """Read luminescence values from the plate reader.""" if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") @@ -287,11 +287,19 @@ async def read_luminescence( ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] # for backend conformity, convert to float, and reshape to 2d array - floats: List[List[Optional[float]]] = [ - [float(int_) for int_ in ints[i : i + 12]] for i in range(0, len(ints), 12) - ] + floats: List[List[Optional[float]]] = utils.reshape_2d( + [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) + ) - return floats + return [ + { + (0, 0): { + "data": floats, + "temp": float("nan"), # Temperature not available + "time": time.time(), + } + } + ] async def read_absorbance( self, @@ -299,7 +307,7 @@ async def read_absorbance( wells: List[Well], wavelength: int, report: Literal["OD", "transmittance"] = "OD", - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: """Read absorbance values from the device. Args: @@ -308,7 +316,8 @@ async def read_absorbance( used interchangeably with "transmission" in the CLARIOStar software and documentation. Returns: - A 2d array of absorbance values, as transmission percentage (values between 0 and 100). + A list containing a single dictionary, where the key is (wavelength, 0) and the value is + another dictionary containing the data, temperature, and time. """ if wells != plate.get_all_items(): @@ -351,14 +360,26 @@ async def read_absorbance( for rcr, rrr in zip(real_chromatic_reading, real_reference_reading): transmittance.append(rcr / rrr * 100) + data: List[List[Optional[float]]] if report == "OD": od: List[Optional[float]] = [] for t in transmittance: - od.append(math.log10(100 / t) if t is not None else None) - return utils.reshape_2d(od, (8, 12)) - - if report == "transmittance": - return utils.reshape_2d(transmittance, (8, 12)) + od.append(math.log10(100 / t) if t is not None and t > 0 else None) + data = utils.reshape_2d(od, (plate.num_items_y, plate.num_items_x)) + elif report == "transmittance": + data = utils.reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) + else: + raise ValueError(f"Invalid report type: {report}") + + return [ + { + (wavelength, 0): { + "data": data, + "temp": float("nan"), # Temperature not available + "time": time.time(), + } + } + ] async def read_fluorescence( self, @@ -367,7 +388,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[List[Optional[float]]]: + ) -> List[Dict[Tuple[int, int], Dict]]: raise NotImplementedError("Not implemented yet") diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 785b8eb7ab0..11aee136944 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -425,7 +425,7 @@ async def _read_now(self) -> None: async def _transfer_data( self, settings: MolecularDevicesSettings - ) -> "MolecularDevicesDataCollection": + ) -> List[Dict[Tuple[int, int], Dict]]: """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each reading and combine them into a single collection. """ @@ -438,26 +438,32 @@ async def _transfer_data( if settings.kinetic_settings else settings.spectrum_settings.num_steps ) - all_reads: List["MolecularDevicesData"] = [] - collection: Optional["MolecularDevicesDataCollection"] = None - + all_reads = [] for _ in range(num_readings): res = await self.send_command("!TRANSFER") data_str = res[1] - parsed_data_collection = self._parse_data(data_str) - - if collection is None: - collection = parsed_data_collection - all_reads.extend(parsed_data_collection.reads) - - collection.reads = all_reads - return collection - + read_data = self._parse_data(data_str, settings) + all_reads.append(read_data) + + if settings.read_type == ReadType.SPECTRUM: + # Combine data for spectrum reads + combined_spectrum = {} + for read_data in all_reads: + for key, value in read_data.items(): + combined_spectrum[key] = { + "data": value["data"], + "temp": value["temp"], + "time": value["time"], + } + return [combined_spectrum] # Return as a list with one element + return all_reads # For KINETIC + + # For ENDPOINT res = await self.send_command("!TRANSFER") data_str = res[1] - return self._parse_data(data_str) + return [self._parse_data(data_str, settings)] - def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": + def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> Dict[Tuple[int, int], Dict]: lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] @@ -465,34 +471,16 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": header_parts = lines[0].split("\t") measurement_time = float(header_parts[0]) temperature = float(header_parts[1]) - container_type = header_parts[2] # 2. Parse wavelengths - absorbance_wavelengths = [] - excitation_wavelengths = [] - emission_wavelengths = [] line_idx = 1 while line_idx < len(lines): line = lines[line_idx] - if line.startswith("L:") and line_idx == 1: - parts = line.split("\t") - for part in parts[1:]: - if part.strip(): - absorbance_wavelengths.append(int(part.strip())) - elif line.startswith("exL:"): - parts = line.split("\t") - for part in parts[1:]: - if part.strip() and part.strip().isdigit(): - excitation_wavelengths.append(int(part.strip())) - elif line.startswith("emL:"): - parts = line.split("\t") - for part in parts[1:]: - if part.strip(): - emission_wavelengths.append(int(part.strip())) - elif line.startswith("L:") and line_idx > 1: + if line.startswith("L:") and line_idx > 1: # Data section started break line_idx += 1 + data_collection = [] cur_read_wavelengths = [] # 3. Parse data @@ -533,67 +521,29 @@ def _parse_data(self, data_str: str) -> "MolecularDevicesDataCollection": data_rows.append(row) data_collection_transposed.append(data_rows) - if absorbance_wavelengths: - reads = [] - for i, data_rows in enumerate(data_collection_transposed): + timepoint_data = {} + read_mode = settings.read_mode + for i, data_rows in enumerate(data_collection_transposed): + key = None + if read_mode == ReadMode.ABS: wl = int(cur_read_wavelengths[i][0]) - reads.append( - MolecularDevicesDataAbsorbance( - measurement_time=measurement_time, - temperature=temperature, - data=data_rows, - absorbance_wavelength=wl, - path_lengths=None, - ) - ) - return MolecularDevicesDataCollectionAbsorbance( - container_type=container_type, - reads=reads, - all_absorbance_wavelengths=absorbance_wavelengths, - data_ordering="row-major", - ) - - elif excitation_wavelengths and emission_wavelengths: - reads = [] - for i, data_rows in enumerate(data_collection_transposed): + key = (wl, 0) + elif read_mode == ReadMode.FLU or read_mode == ReadMode.POLAR or read_mode == ReadMode.TIME: ex_wl = int(cur_read_wavelengths[i][0]) em_wl = int(cur_read_wavelengths[i][1]) - reads.append( - MolecularDevicesDataFluorescence( - measurement_time=measurement_time, - temperature=temperature, - data=data_rows, - excitation_wavelength=ex_wl, - emission_wavelength=em_wl, - ) - ) - return MolecularDevicesDataCollectionFluorescence( - container_type=container_type, - reads=reads, - all_excitation_wavelengths=excitation_wavelengths, - all_emission_wavelengths=emission_wavelengths, - data_ordering="row-major", - ) - elif emission_wavelengths: - reads = [] - for i, data_rows in enumerate(data_collection_transposed): + key = (ex_wl, em_wl) + elif read_mode == ReadMode.LUM: em_wl = int(cur_read_wavelengths[i][1]) - reads.append( - MolecularDevicesDataLuminescence( - measurement_time=measurement_time, - temperature=temperature, - data=data_rows, - emission_wavelength=em_wl, - ) - ) - return MolecularDevicesDataCollectionLuminescence( - container_type=container_type, - reads=reads, - all_emission_wavelengths=emission_wavelengths, - data_ordering="row-major", - ) - # Default to generic MolecularDevicesData if no specific wavelengths found - raise ValueError("Unable to determine data type from response.") + key = (0, em_wl) + + if key: + timepoint_data[key] = { + "data": data_rows, + "temp": temperature, + "time": measurement_time, + } + + return timepoint_data async def _set_clear(self) -> None: await self.send_command("!CLEAR DATA") @@ -816,7 +766,7 @@ async def read_absorbance( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> MolecularDevicesDataCollection: + ) -> List[Dict[Tuple[int, int], Dict]]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.ABS, @@ -934,7 +884,7 @@ async def read_luminescence( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> MolecularDevicesDataCollection: + ) -> List[Dict[Tuple[int, int], Dict]]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.LUM, @@ -995,7 +945,7 @@ async def read_fluorescence_polarization( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> MolecularDevicesDataCollection: + ) -> List[Dict[Tuple[int, int], Dict]]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.POLAR, @@ -1060,7 +1010,7 @@ async def read_time_resolved_fluorescence( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> MolecularDevicesDataCollection: + ) -> List[Dict[Tuple[int, int], Dict]]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.TIME, diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index c6bd1259739..027e4c20f85 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -688,17 +688,26 @@ def test_parse_absorbance_single_wavelength(self): 1:\t0.1\t0.2 2:\t0.3\t0.4 """ - result = self.backend._parse_data(data_str) - self.assertIsInstance(result, MolecularDevicesDataCollectionAbsorbance) - self.assertEqual(result.container_type, "96-well") - self.assertEqual(result.all_absorbance_wavelengths, [260]) - self.assertEqual(len(result.reads), 1) - read = result.reads[0] - self.assertIsInstance(read, MolecularDevicesDataAbsorbance) - self.assertEqual(read.measurement_time, 12345.6) - self.assertEqual(read.temperature, 25.1) - self.assertEqual(read.absorbance_wavelength, 260) - self.assertEqual(read.data, [[0.1, 0.3], [0.2, 0.4]]) + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._parse_data(data_str, settings) + self.assertIsInstance(result, dict) + self.assertEqual(len(result), 1) + self.assertIn((260, 0), result) + read = result[(260, 0)] + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["data"], [[0.1, 0.3], [0.2, 0.4]]) def test_parse_absorbance_multiple_wavelengths(self): data_str = """ @@ -711,16 +720,27 @@ def test_parse_absorbance_multiple_wavelengths(self): 1:\t0.5\t0.6 2:\t0.7\t0.8 """ - result = self.backend._parse_data(data_str) - self.assertIsInstance(result, MolecularDevicesDataCollectionAbsorbance) - self.assertEqual(result.all_absorbance_wavelengths, [260, 280]) - self.assertEqual(len(result.reads), 2) - read1 = result.reads[0] - self.assertEqual(read1.absorbance_wavelength, 260) - self.assertEqual(read1.data, [[0.1, 0.3], [0.2, 0.4]]) - read2 = result.reads[1] - self.assertEqual(read2.absorbance_wavelength, 280) - self.assertEqual(read2.data, [[0.5, 0.7], [0.6, 0.8]]) + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._parse_data(data_str, settings) + self.assertIsInstance(result, dict) + self.assertEqual(len(result), 2) + self.assertIn((260, 0), result) + read1 = result[(260, 0)] + self.assertEqual(read1["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertIn((280, 0), result) + read2 = result[(280, 0)] + self.assertEqual(read2["data"], [[0.5, 0.7], [0.6, 0.8]]) def test_parse_fluorescence(self): data_str = """ @@ -731,16 +751,26 @@ def test_parse_fluorescence(self): 1:\t100\t200 2:\t300\t400 """ - result = self.backend._parse_data(data_str) - self.assertIsInstance(result, MolecularDevicesDataCollectionFluorescence) - self.assertEqual(result.all_excitation_wavelengths, [485]) - self.assertEqual(result.all_emission_wavelengths, [520]) - self.assertEqual(len(result.reads), 1) - read = result.reads[0] - self.assertIsInstance(read, MolecularDevicesDataFluorescence) - self.assertEqual(read.excitation_wavelength, 485) - self.assertEqual(read.emission_wavelength, 520) - self.assertEqual(read.data, [[100.0, 300.0], [200.0, 400.0]]) + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._parse_data(data_str, settings) + self.assertIsInstance(result, dict) + self.assertEqual(len(result), 1) + self.assertIn((485, 520), result) + read = result[(485, 520)] + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["data"], [[100.0, 300.0], [200.0, 400.0]]) def test_parse_luminescence(self): data_str = """ @@ -750,14 +780,26 @@ def test_parse_luminescence(self): 1:\t1000\t2000 2:\t3000\t4000 """ - result = self.backend._parse_data(data_str) - self.assertIsInstance(result, MolecularDevicesDataCollectionLuminescence) - self.assertEqual(result.all_emission_wavelengths, [590]) - self.assertEqual(len(result.reads), 1) - read = result.reads[0] - self.assertIsInstance(read, MolecularDevicesDataLuminescence) - self.assertEqual(read.emission_wavelength, 590) - self.assertEqual(read.data, [[1000.0, 3000.0], [2000.0, 4000.0]]) + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.LUM, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._parse_data(data_str, settings) + self.assertIsInstance(result, dict) + self.assertEqual(len(result), 1) + self.assertIn((0, 590), result) + read = result[(0, 590)] + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["data"], [[1000.0, 3000.0], [2000.0, 4000.0]]) def test_parse_data_with_sat_and_nan(self): data_str = """ @@ -767,10 +809,22 @@ def test_parse_data_with_sat_and_nan(self): 1:\t0.1\t#SAT 2:\t0.3\t- """ - result = self.backend._parse_data(data_str) - read = result.reads[0] - self.assertEqual(read.data[1][0], float("inf")) - self.assertTrue(math.isnan(read.data[1][1])) + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._parse_data(data_str, settings) + read = result[(260, 0)] + self.assertEqual(read["data"][1][0], float("inf")) + self.assertTrue(math.isnan(read["data"][1][1])) async def test_parse_kinetic_absorbance(self): # Mock the send_command to return two different data blocks @@ -812,11 +866,63 @@ def data_generator(): ) result = await self.backend._transfer_data(settings) - self.assertEqual(len(result.reads), 2) - self.assertEqual(result.reads[0].data, [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(result.reads[1].data, [[0.15, 0.35], [0.25, 0.45]]) - self.assertEqual(result.reads[0].measurement_time, 12345.6) - self.assertEqual(result.reads[1].measurement_time, 12355.6) + self.assertEqual(len(result), 2) + self.assertEqual(result[0][(260, 0)]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[1][(260, 0)]["data"], [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result[0][(260, 0)]["time"], 12345.6) + self.assertEqual(result[1][(260, 0)]["time"], 12355.6) + + async def test_parse_spectrum_absorbance(self): + # Mock the send_command to return two different data blocks for two wavelengths + def data_generator(): + yield [ + "OK", + """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + """, + ] + yield [ + "OK", + """ + 12355.6\t25.2\t96-well + L:\t270 + L:\t270 + 1:\t0.15\t0.25 + 2:\t0.35\t0.45 + """, + ] + + self.backend.send_command = AsyncMock(side_effect=data_generator()) + + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.SPECTRUM, + spectrum_settings=SpectrumSettings(start_wavelength=260, step=10, num_steps=2), + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + ) + + result = await self.backend._transfer_data(settings) + self.assertEqual(len(result), 1) # Should return a list with one dict + combined_data = result[0] + self.assertEqual(len(combined_data), 2) # Two wavelengths + + self.assertIn((260, 0), combined_data) + self.assertEqual(combined_data[(260, 0)]["data"], [[[0.1, 0.3], [0.2, 0.4]]]) + self.assertEqual(combined_data[(260, 0)]["time"], 12345.6) + + self.assertIn((270, 0), combined_data) + self.assertEqual(combined_data[(270, 0)]["data"], [[[0.15, 0.35], [0.25, 0.45]]]) + self.assertEqual(combined_data[(270, 0)]["time"], 12355.6) class TestErrorHandling(unittest.IsolatedAsyncioTestCase): From ee53ed07e3579a0006cf5a9e3aba3a7ae6423c34 Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Wed, 5 Nov 2025 09:57:22 -0800 Subject: [PATCH 15/18] fix types --- pylabrobot/plate_reading/biotek_backend.py | 6 +- pylabrobot/plate_reading/biotek_tests.py | 27 +-- pylabrobot/plate_reading/chatterbox.py | 6 +- .../molecular_devices_backend.py | 115 +++------ .../molecular_devices_backend_tests.py | 218 +++++++++--------- ...lar_devices_spectramax_384_plus_backend.py | 101 +++++++- pylabrobot/plate_reading/plate_reader.py | 27 ++- 7 files changed, 268 insertions(+), 232 deletions(-) diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py index b33d9a6f492..f0e8efe84a7 100644 --- a/pylabrobot/plate_reading/biotek_backend.py +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -614,7 +614,7 @@ async def stop_heating_or_cooling(self): def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: assert self._plate is not None, "Plate must be set before reading data" - plate=self._plate + plate = self._plate start_index = 22 end_index = body.rindex(b"\r\n") num_rows = plate.num_items_y @@ -635,8 +635,8 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: result: List[List[Optional[float]]] = [ [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) ] - for (row, col), value in parsed_data.items(): - result[row][col] = value + for (row_idx, col_idx), value in parsed_data.items(): + result[row_idx][col_idx] = value return result async def set_plate(self, plate: Plate): diff --git a/pylabrobot/plate_reading/biotek_tests.py b/pylabrobot/plate_reading/biotek_tests.py index e8ae3a7b6ba..68b2494ac0f 100644 --- a/pylabrobot/plate_reading/biotek_tests.py +++ b/pylabrobot/plate_reading/biotek_tests.py @@ -4,7 +4,6 @@ import unittest import unittest.mock from typing import Iterator -import time from pylabrobot.plate_reading.biotek_backend import Cytation5Backend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb @@ -188,13 +187,7 @@ async def test_read_absorbance(self): [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], ] - self.assertEqual(resp, [{ - (580, 0): { - "data": expected_data, - "temp": 23.6, - "time": 12345.6789 - } - }]) + self.assertEqual(resp, [{(580, 0): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}]) async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( @@ -250,13 +243,7 @@ async def test_read_luminescence_partial(self): [0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None], [None, None, None, None, None, None, None, None, None, None, None, None], ] - self.assertEqual(resp, [{ - (0, 0): { - "data": expected_data, - "temp": 23.6, - "time": 12345.6789 - } - }]) + self.assertEqual(resp, [{(0, 0): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}]) async def test_read_fluorescence(self): self.backend.io.read.side_effect = _byte_iter( @@ -317,10 +304,6 @@ async def test_read_fluorescence(self): [653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0], [1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0], ] - self.assertEqual(resp, [{ - (485, 528): { - "data": expected_data, - "temp": 23.6, - "time": 12345.6789 - } - }]) + self.assertEqual( + resp, [{(485, 528): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}] + ) diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py index 4a71251bd33..e105e4e57c2 100644 --- a/pylabrobot/plate_reading/chatterbox.py +++ b/pylabrobot/plate_reading/chatterbox.py @@ -1,5 +1,5 @@ -from typing import Dict, List, Optional, Tuple import time +from typing import Dict, List, Optional, Tuple from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well @@ -71,7 +71,9 @@ def _mask_result( masked[r][c] = result[r][c] return masked - def _format_data(self, data: List[List[Optional[float]]], key: Tuple[int, int]) -> List[Dict[Tuple[int, int], Dict]]: + def _format_data( + self, data: List[List[Optional[float]]], key: Tuple[int, int] + ) -> List[Dict[Tuple[int, int], Dict]]: return [ { key: { diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 11aee136944..cf1d040e0d5 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -246,72 +246,6 @@ class MolecularDevicesSettings: settling_time: int = 0 -@dataclass -class MolecularDevicesData: - """Data from a Molecular Devices plate reader.""" - - measurement_time: float - temperature: float - data: List[List[float]] - - -@dataclass -class MolecularDevicesDataAbsorbance(MolecularDevicesData): - """Absorbance data from a Molecular Devices plate reader.""" - - absorbance_wavelength: int - path_lengths: Optional[List[List[float]]] = None - - -@dataclass -class MolecularDevicesDataFluorescence(MolecularDevicesData): - """Fluorescence data from a Molecular Devices plate reader.""" - - excitation_wavelength: int - emission_wavelength: int - - -@dataclass -class MolecularDevicesDataLuminescence(MolecularDevicesData): - """Luminescence data from a Molecular Devices plate reader.""" - - emission_wavelength: int - - -@dataclass -class MolecularDevicesDataCollection: - """A collection of MolecularDevicesData objects from multiple reads.""" - - container_type: str - reads: List["MolecularDevicesData"] - data_ordering: str - - -@dataclass -class MolecularDevicesDataCollectionAbsorbance(MolecularDevicesDataCollection): - """A collection of MolecularDevicesDataAbsorbance objects from multiple reads.""" - - reads: List["MolecularDevicesDataAbsorbance"] - all_absorbance_wavelengths: List[int] - - -@dataclass -class MolecularDevicesDataCollectionFluorescence(MolecularDevicesDataCollection): - """A collection of MolecularDevicesDataFluorescence objects from multiple reads.""" - - reads: List["MolecularDevicesDataFluorescence"] - all_excitation_wavelengths: List[int] - all_emission_wavelengths: List[int] - - -@dataclass -class MolecularDevicesDataCollectionLuminescence(MolecularDevicesDataCollection): - """A collection of MolecularDevicesDataLuminescence objects from multiple reads.""" - - reads: List["MolecularDevicesDataLuminescence"] - all_emission_wavelengths: List[int] - - class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): """Backend for Molecular Devices plate readers.""" @@ -411,8 +345,9 @@ async def set_temperature(self, temperature: float) -> None: raise ValueError("Temperature must be between 0 and 45°C.") await self.send_command(f"!TEMP {temperature}") - async def get_firmware_version(self) -> str: - return await self.send_command("!OPTION") + async def get_firmware_version(self) -> List[str]: + res = await self.send_command("!OPTION") + return res[1].split() async def start_shake(self) -> None: await self.send_command("!SHAKE NOW") @@ -433,11 +368,13 @@ async def _transfer_data( if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings ): - num_readings = ( - settings.kinetic_settings.num_readings - if settings.kinetic_settings - else settings.spectrum_settings.num_steps - ) + if settings.kinetic_settings: + num_readings = settings.kinetic_settings.num_readings + elif settings.spectrum_settings: + num_readings = settings.spectrum_settings.num_steps + else: + raise ValueError("Kinetic or Spectrum settings must be provided for this read type.") + all_reads = [] for _ in range(num_readings): res = await self.send_command("!TRANSFER") @@ -450,20 +387,22 @@ async def _transfer_data( combined_spectrum = {} for read_data in all_reads: for key, value in read_data.items(): - combined_spectrum[key] = { - "data": value["data"], - "temp": value["temp"], - "time": value["time"], - } - return [combined_spectrum] # Return as a list with one element - return all_reads # For KINETIC + combined_spectrum[key] = { + "data": value["data"], + "temp": value["temp"], + "time": value["time"], + } + return [combined_spectrum] # Return as a list with one element + return all_reads # For KINETIC # For ENDPOINT res = await self.send_command("!TRANSFER") data_str = res[1] return [self._parse_data(data_str, settings)] - def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> Dict[Tuple[int, int], Dict]: + def _parse_data( + self, data_str: str, settings: MolecularDevicesSettings + ) -> Dict[Tuple[int, int], Dict]: lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] @@ -484,7 +423,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> Dict data_collection = [] cur_read_wavelengths = [] # 3. Parse data - data_columns = [] + data_columns: List[List[float]] = [] # The data section starts at line_idx for i in range(line_idx, len(lines)): line = lines[i] @@ -586,10 +525,14 @@ async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: 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 @@ -750,7 +693,7 @@ async def _wait_for_idle(self, timeout: int = 600): break await asyncio.sleep(1) - async def read_absorbance( + async def read_absorbance( # type: ignore[override] self, plate: Plate, wavelengths: List[Union[int, Tuple[int, bool]]], @@ -803,7 +746,7 @@ async def read_absorbance( await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) - async def read_fluorescence( + async def read_fluorescence( # type: ignore[override] self, plate: Plate, excitation_wavelengths: List[int], @@ -822,7 +765,7 @@ async def read_fluorescence( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> MolecularDevicesDataCollection: + ) -> List[Dict[Tuple[int, int], Dict]]: """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" settings = MolecularDevicesSettings( plate=plate, @@ -867,7 +810,7 @@ async def read_fluorescence( await self._wait_for_idle(timeout=timeout) return await self._transfer_data(settings) - async def read_luminescence( + async def read_luminescence( # type: ignore[override] self, plate: Plate, emission_wavelengths: List[int], diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 027e4c20f85..79ee1913173 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -7,12 +7,6 @@ CarriageSpeed, KineticSettings, MolecularDevicesBackend, - MolecularDevicesDataAbsorbance, - MolecularDevicesDataCollectionAbsorbance, - MolecularDevicesDataCollectionFluorescence, - MolecularDevicesDataCollectionLuminescence, - MolecularDevicesDataFluorescence, - MolecularDevicesDataLuminescence, MolecularDevicesError, MolecularDevicesSettings, MolecularDevicesUnrecognizedCommandError, @@ -27,6 +21,10 @@ class TestMolecularDevicesBackend(unittest.IsolatedAsyncioTestCase): + backend: MolecularDevicesBackend + mock_serial: MagicMock + send_command_mock: AsyncMock + def setUp(self): self.mock_serial = MagicMock() self.mock_serial.setup = AsyncMock() @@ -37,20 +35,25 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="COM1") self.backend.io = self.mock_serial - self.backend.send_command = AsyncMock() + self.send_command_mock = patch.object( + self.backend, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) async def test_setup_stop(self): # un-mock send_command for this test - self.backend.send_command = AsyncMock(wraps=self.backend.send_command) - await self.backend.setup() - self.mock_serial.setup.assert_called_once() - self.backend.send_command.assert_called_with("!") - await self.backend.stop() - self.mock_serial.stop.assert_called_once() + with patch.object( + self.backend, "send_command", wraps=self.backend.send_command + ) as wrapped_send_command: + await self.backend.setup() + self.mock_serial.setup.assert_called_once() + wrapped_send_command.assert_called_with("!") + await self.backend.stop() + self.mock_serial.stop.assert_called_once() async def test_set_clear(self): await self.backend._set_clear() - self.backend.send_command.assert_called_once_with("!CLEAR DATA") + self.send_command_mock.assert_called_once_with("!CLEAR DATA") async def test_set_mode(self): settings = MolecularDevicesSettings( @@ -66,24 +69,24 @@ async def test_set_mode(self): spectrum_settings=None, ) await self.backend._set_mode(settings) - self.backend.send_command.assert_called_once_with("!MODE ENDPOINT") + self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.read_type = ReadType.KINETIC settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) await self.backend._set_mode(settings) - self.backend.send_command.assert_called_once_with("!MODE KINETIC 10 5") + self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.read_type = ReadType.SPECTRUM settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) await self.backend._set_mode(settings) - self.backend.send_command.assert_called_once_with("!MODE SPECTRUM 200 10 50") + self.send_command_mock.assert_called_once_with("!MODE SPECTRUM 200 10 50") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" await self.backend._set_mode(settings) - self.backend.send_command.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") + self.send_command_mock.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") async def test_set_wavelengths(self): settings = MolecularDevicesSettings( @@ -100,27 +103,25 @@ async def test_set_wavelengths(self): spectrum_settings=None, ) await self.backend._set_wavelengths(settings) - self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600") + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.path_check = True await self.backend._set_wavelengths(settings) - self.backend.send_command.assert_called_once_with("!WAVELENGTH 500 F600 900 998") + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600 900 998") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] await self.backend._set_wavelengths(settings) - self.backend.send_command.assert_has_calls( - [call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")] - ) + self.send_command_mock.assert_has_calls([call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")]) - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.LUM settings.emission_wavelengths = [590] await self.backend._set_wavelengths(settings) - self.backend.send_command.assert_called_once_with("!EMWAVELENGTH 590") + self.send_command_mock.assert_called_once_with("!EMWAVELENGTH 590") async def test_set_plate_position(self): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") @@ -137,7 +138,7 @@ async def test_set_plate_position(self): spectrum_settings=None, ) await self.backend._set_plate_position(settings) - self.backend.send_command.assert_has_calls( + self.send_command_mock.assert_has_calls( [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] ) @@ -156,7 +157,7 @@ async def test_set_strip(self): spectrum_settings=None, ) await self.backend._set_strip(settings) - self.backend.send_command.assert_called_once_with("!STRIP 1 12") + self.send_command_mock.assert_called_once_with("!STRIP 1 12") async def test_set_shake(self): settings = MolecularDevicesSettings( @@ -172,18 +173,18 @@ async def test_set_shake(self): spectrum_settings=None, ) await self.backend._set_shake(settings) - self.backend.send_command.assert_called_once_with("!SHAKE OFF") + self.send_command_mock.assert_called_once_with("!SHAKE OFF") - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) await self.backend._set_shake(settings) - self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) - self.backend.send_command.reset_mock() + self.send_command_mock.reset_mock() settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) await self.backend._set_shake(settings) - self.backend.send_command.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) async def test_set_carriage_speed(self): settings = MolecularDevicesSettings( @@ -199,11 +200,11 @@ async def test_set_carriage_speed(self): spectrum_settings=None, ) await self.backend._set_carriage_speed(settings) - self.backend.send_command.assert_called_once_with("!CSPEED 8") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!CSPEED 8") + self.send_command_mock.reset_mock() settings.carriage_speed = CarriageSpeed.SLOW await self.backend._set_carriage_speed(settings) - self.backend.send_command.assert_called_once_with("!CSPEED 1") + self.send_command_mock.assert_called_once_with("!CSPEED 1") async def test_set_read_stage(self): settings = MolecularDevicesSettings( @@ -219,15 +220,15 @@ async def test_set_read_stage(self): spectrum_settings=None, ) await self.backend._set_read_stage(settings) - self.backend.send_command.assert_called_once_with("!READSTAGE TOP") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!READSTAGE TOP") + self.send_command_mock.reset_mock() settings.read_from_bottom = True await self.backend._set_read_stage(settings) - self.backend.send_command.assert_called_once_with("!READSTAGE BOT") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!READSTAGE BOT") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS await self.backend._set_read_stage(settings) - self.backend.send_command.assert_not_called() + self.send_command_mock.assert_not_called() async def test_set_flashes_per_well(self): settings = MolecularDevicesSettings( @@ -244,11 +245,11 @@ async def test_set_flashes_per_well(self): spectrum_settings=None, ) await self.backend._set_flashes_per_well(settings) - self.backend.send_command.assert_called_once_with("!FPW 10") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!FPW 10") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS await self.backend._set_flashes_per_well(settings) - self.backend.send_command.assert_not_called() + self.send_command_mock.assert_not_called() async def test_set_pmt(self): settings = MolecularDevicesSettings( @@ -265,19 +266,19 @@ async def test_set_pmt(self): spectrum_settings=None, ) await self.backend._set_pmt(settings) - self.backend.send_command.assert_called_once_with("!AUTOPMT ON") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!AUTOPMT ON") + self.send_command_mock.reset_mock() settings.pmt_gain = PmtGain.HIGH await self.backend._set_pmt(settings) - self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) - self.backend.send_command.reset_mock() + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) + self.send_command_mock.reset_mock() settings.pmt_gain = 9 await self.backend._set_pmt(settings) - self.backend.send_command.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) - self.backend.send_command.reset_mock() + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS await self.backend._set_pmt(settings) - self.backend.send_command.assert_not_called() + self.send_command_mock.assert_not_called() async def test_set_filter(self): settings = MolecularDevicesSettings( @@ -294,16 +295,16 @@ async def test_set_filter(self): spectrum_settings=None, ) await self.backend._set_filter(settings) - self.backend.send_command.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) - self.backend.send_command.reset_mock() + self.send_command_mock.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) + self.send_command_mock.reset_mock() settings.cutoff_filters = [] await self.backend._set_filter(settings) - self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] await self.backend._set_filter(settings) - self.backend.send_command.assert_called_once_with("!AUTOFILTER ON") + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") async def test_set_calibrate(self): settings = MolecularDevicesSettings( @@ -319,11 +320,11 @@ async def test_set_calibrate(self): spectrum_settings=None, ) await self.backend._set_calibrate(settings) - self.backend.send_command.assert_called_once_with("!CALIBRATE ON") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!CALIBRATE ON") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU await self.backend._set_calibrate(settings) - self.backend.send_command.assert_called_once_with("!PMTCAL ON") + self.send_command_mock.assert_called_once_with("!PMTCAL ON") async def test_set_order(self): settings = MolecularDevicesSettings( @@ -339,11 +340,11 @@ async def test_set_order(self): spectrum_settings=None, ) await self.backend._set_order(settings) - self.backend.send_command.assert_called_once_with("!ORDER COLUMN") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!ORDER COLUMN") + self.send_command_mock.reset_mock() settings.read_order = ReadOrder.WAVELENGTH await self.backend._set_order(settings) - self.backend.send_command.assert_called_once_with("!ORDER WAVELENGTH") + self.send_command_mock.assert_called_once_with("!ORDER WAVELENGTH") async def test_set_speed(self): settings = MolecularDevicesSettings( @@ -359,15 +360,15 @@ async def test_set_speed(self): spectrum_settings=None, ) await self.backend._set_speed(settings) - self.backend.send_command.assert_called_once_with("!SPEED ON") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!SPEED ON") + self.send_command_mock.reset_mock() settings.speed_read = False await self.backend._set_speed(settings) - self.backend.send_command.assert_called_once_with("!SPEED OFF") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!SPEED OFF") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU await self.backend._set_speed(settings) - self.backend.send_command.assert_not_called() + self.send_command_mock.assert_not_called() async def test_set_integration_time(self): settings = MolecularDevicesSettings( @@ -383,11 +384,11 @@ async def test_set_integration_time(self): spectrum_settings=None, ) await self.backend._set_integration_time(settings, 10, 100) - self.backend.send_command.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) - self.backend.send_command.reset_mock() + self.send_command_mock.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS await self.backend._set_integration_time(settings, 10, 100) - self.backend.send_command.assert_not_called() + self.send_command_mock.assert_not_called() async def test_set_nvram_polar(self): settings = MolecularDevicesSettings( @@ -404,7 +405,7 @@ async def test_set_nvram_polar(self): settling_time=5, ) await self.backend._set_nvram(settings) - self.backend.send_command.assert_called_once_with("!NVRAM FPSETTLETIME 5") + self.send_command_mock.assert_called_once_with("!NVRAM FPSETTLETIME 5") async def test_set_nvram_other(self): settings = MolecularDevicesSettings( @@ -421,11 +422,11 @@ async def test_set_nvram_other(self): settling_time=10, ) await self.backend._set_nvram(settings) - self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 100") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 100") + self.send_command_mock.reset_mock() settings.settling_time = 110 await self.backend._set_nvram(settings) - self.backend.send_command.assert_called_once_with("!NVRAM CARCOL 110") + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 110") async def test_set_tag(self): settings = MolecularDevicesSettings( @@ -441,16 +442,16 @@ async def test_set_tag(self): spectrum_settings=None, ) await self.backend._set_tag(settings) - self.backend.send_command.assert_called_once_with("!TAG ON") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!TAG ON") + self.send_command_mock.reset_mock() settings.read_type = ReadType.ENDPOINT await self.backend._set_tag(settings) - self.backend.send_command.assert_called_once_with("!TAG OFF") - self.backend.send_command.reset_mock() + self.send_command_mock.assert_called_once_with("!TAG OFF") + self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.read_type = ReadType.KINETIC await self.backend._set_tag(settings) - self.backend.send_command.assert_called_once_with("!TAG OFF") + self.send_command_mock.assert_called_once_with("!TAG OFF") @patch( "pylabrobot.plate_reading.molecular_devices_backend.MolecularDevicesBackend._wait_for_idle", @@ -469,7 +470,7 @@ async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wai plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") await self.backend.read_absorbance(plate, [500]) - commands = [c.args[0] for c in self.backend.send_command.call_args_list] + commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertIn("!STRIP 1 12", commands) self.assertIn("!CSPEED 8", commands) @@ -481,7 +482,7 @@ async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wai self.assertIn("!SPEED OFF", commands) readtype_call = next( - c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE ABSPLA" + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE ABSPLA" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) @@ -506,7 +507,7 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") await self.backend.read_fluorescence(plate, [485], [520], [515]) - commands = [c.args[0] for c in self.backend.send_command.call_args_list] + commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) @@ -525,7 +526,7 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w self.assertIn("!READSTAGE TOP", commands) readtype_call = next( - c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE FLU" + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE FLU" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) @@ -550,7 +551,7 @@ async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_w plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") await self.backend.read_luminescence(plate, [590]) - commands = [c.args[0] for c in self.backend.send_command.call_args_list] + commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) @@ -564,7 +565,7 @@ async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_w self.assertIn("!READSTAGE TOP", commands) readtype_call = next( - c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE LUM" + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE LUM" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) @@ -594,7 +595,7 @@ async def test_read_fluorescence_polarization( plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") await self.backend.read_fluorescence_polarization(plate, [485], [520], [515]) - commands = [c.args[0] for c in self.backend.send_command.call_args_list] + commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) @@ -613,7 +614,7 @@ async def test_read_fluorescence_polarization( self.assertIn("!READSTAGE TOP", commands) readtype_call = next( - c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE POLAR" + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE POLAR" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) @@ -642,10 +643,10 @@ async def test_read_time_resolved_fluorescence( ): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") await self.backend.read_time_resolved_fluorescence( - plate, [485], [520], [515], delay_time=10, integration_time=100 - ) + plate, [485], [520], [515], delay_time=10, integration_time=100 + ) - commands = [c.args[0] for c in self.backend.send_command.call_args_list] + commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) @@ -666,7 +667,7 @@ async def test_read_time_resolved_fluorescence( self.assertIn("!READSTAGE TOP", commands) readtype_call = next( - c for c in self.backend.send_command.call_args_list if c.args[0] == "!READTYPE TIME 0 250" + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE TIME 0 250" ) self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) @@ -676,17 +677,22 @@ async def test_read_time_resolved_fluorescence( class TestDataParsing(unittest.IsolatedAsyncioTestCase): + send_command_mock: AsyncMock + def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): self.backend = MolecularDevicesBackend(port="COM1") + self.send_command_mock = patch.object( + self.backend, "send_command", new_callable=AsyncMock + ).start() def test_parse_absorbance_single_wavelength(self): data_str = """ - 12345.6\t25.1\t96-well - L:\t260 - L:\t260 - 1:\t0.1\t0.2 - 2:\t0.3\t0.4 + 12345.6 25.1 96-well + L: 260 + L: 260 + 1: 0.1 0.2 + 2: 0.3 0.4 """ settings = MolecularDevicesSettings( plate=MagicMock(), @@ -850,7 +856,7 @@ def data_generator(): """, ] - self.backend.send_command = AsyncMock(side_effect=data_generator()) + self.send_command_mock.side_effect = data_generator() settings = MolecularDevicesSettings( plate=MagicMock(), @@ -896,7 +902,7 @@ def data_generator(): """, ] - self.backend.send_command = AsyncMock(side_effect=data_generator()) + self.send_command_mock.side_effect = data_generator() settings = MolecularDevicesSettings( plate=MagicMock(), @@ -917,11 +923,11 @@ def data_generator(): self.assertEqual(len(combined_data), 2) # Two wavelengths self.assertIn((260, 0), combined_data) - self.assertEqual(combined_data[(260, 0)]["data"], [[[0.1, 0.3], [0.2, 0.4]]]) + self.assertEqual(combined_data[(260, 0)]["data"], [[0.1, 0.3], [0.2, 0.4]]) self.assertEqual(combined_data[(260, 0)]["time"], 12345.6) self.assertIn((270, 0), combined_data) - self.assertEqual(combined_data[(270, 0)]["data"], [[[0.15, 0.35], [0.25, 0.45]]]) + self.assertEqual(combined_data[(270, 0)]["data"], [[0.15, 0.35], [0.25, 0.45]]) self.assertEqual(combined_data[(270, 0)]["time"], 12355.6) @@ -967,7 +973,7 @@ async def test_parse_basic_errors_empty_response(self): # Test an empty response from the device self.mock_serial.readline.return_value = b"" # Simulate no response with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): - await self.backend.send_command("!TEST", timeout=0.01) # Short timeout for test + await self.backend.send_command("!TEST", timeout=1) # Short timeout for test async def test_parse_basic_errors_warning_response(self): # Test a response containing a warning @@ -990,4 +996,4 @@ async def test_parse_basic_errors_ok_response(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index b44afd8d447..fea84566b68 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -1,6 +1,19 @@ -from typing import List +from typing import Dict, List, Optional, Tuple, Union -from .molecular_devices_backend import MolecularDevicesBackend, MolecularDevicesSettings +from pylabrobot.resources.plate import Plate + +from .molecular_devices_backend import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesSettings, + PmtGain, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): @@ -20,14 +33,90 @@ async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: async def _set_tag(self, settings: MolecularDevicesSettings) -> None: pass - async def read_fluorescence(self, *args, **kwargs) -> List[List[float]]: + async def read_fluorescence( # type: ignore[override] + self, + plate: "Plate", + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional["ShakeSettings"] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional["KineticSettings"] = None, + spectrum_settings: Optional["SpectrumSettings"] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict[Tuple[int, int], Dict]]: raise NotImplementedError("Fluorescence reading is not supported.") - async def read_luminescence(self, *args, **kwargs) -> List[List[float]]: + async def read_luminescence( # type: ignore[override] + self, + plate: "Plate", + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional["ShakeSettings"] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional["KineticSettings"] = None, + spectrum_settings: Optional["SpectrumSettings"] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict[Tuple[int, int], Dict]]: raise NotImplementedError("Luminescence reading is not supported.") - async def read_fluorescence_polarization(self, *args, **kwargs) -> List[List[float]]: + async def read_fluorescence_polarization( + self, + plate: "Plate", + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional["ShakeSettings"] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional["KineticSettings"] = None, + spectrum_settings: Optional["SpectrumSettings"] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict[Tuple[int, int], Dict]]: raise NotImplementedError("Fluorescence polarization reading is not supported.") - async def read_time_resolved_fluorescence(self, *args, **kwargs) -> List[List[float]]: + async def read_time_resolved_fluorescence( + self, + plate: "Plate", + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional["ShakeSettings"] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional["KineticSettings"] = None, + spectrum_settings: Optional["SpectrumSettings"] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict[Tuple[int, int], Dict]]: raise NotImplementedError("Time-resolved fluorescence reading is not supported.") diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index 21d7f35a096..b9ef1383c75 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -1,5 +1,5 @@ import logging -from typing import List, Optional, cast +from typing import Dict, List, Optional, Tuple, cast from pylabrobot.machines.machine import Machine, need_setup_finished from pylabrobot.plate_reading.backend import PlateReaderBackend @@ -75,11 +75,15 @@ async def close(self, **backend_kwargs) -> None: @need_setup_finished async def read_luminescence( self, focal_height: float, wells: Optional[List[Well]] = None, **backend_kwargs - ) -> List[List[Optional[float]]]: - """Read the luminescence from the plate. + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the luminescence from the plate reader. Args: focal_height: The focal height to read the luminescence at, in micrometers. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key (0, 0) + and a value containing the data, temperature, and time. """ return await self.backend.read_luminescence( @@ -92,11 +96,15 @@ async def read_luminescence( @need_setup_finished async def read_absorbance( self, wavelength: int, wells: Optional[List[Well]] = None, **backend_kwargs - ) -> List[List[Optional[float]]]: - """Read the absorbance from the plate in OD, unless otherwise specified by the backend. + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the absorbance from the plate reader. Args: wavelength: The wavelength to read the absorbance at, in nanometers. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key (wavelength, 0) + and a value containing the data, temperature, and time. """ return await self.backend.read_absorbance( @@ -114,13 +122,18 @@ async def read_fluorescence( focal_height: float, wells: Optional[List[Well]] = None, **backend_kwargs, - ) -> List[List[Optional[float]]]: - """ + ) -> List[Dict[Tuple[int, int], Dict]]: + """Read the fluorescence from the plate reader. Args: excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. focal_height: The focal height to read the fluorescence at, in micrometers. + + Returns: + A list of dictionaries, one for each timepoint. Each dictionary has a key + (excitation_wavelength, emission_wavelength) and a value containing the data, temperature, + and time. """ if excitation_wavelength > emission_wavelength: From 469a6df56f5b188d8e912ac51274ab0de54af89d Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Sat, 8 Nov 2025 19:44:59 -0800 Subject: [PATCH 16/18] change read function output format --- pylabrobot/plate_reading/backend.py | 28 +++++--- pylabrobot/plate_reading/biotek_backend.py | 33 ++++----- pylabrobot/plate_reading/biotek_tests.py | 36 +++++++++- pylabrobot/plate_reading/chatterbox.py | 47 +++++++------ .../plate_reading/clario_star_backend.py | 21 +++--- .../molecular_devices_backend.py | 57 ++++++---------- .../molecular_devices_backend_tests.py | 68 ++++++++++--------- ...lar_devices_spectramax_384_plus_backend.py | 8 +-- pylabrobot/plate_reading/plate_reader.py | 28 +++++--- 9 files changed, 182 insertions(+), 144 deletions(-) diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/plate_reading/backend.py index 3c08f5812e5..48d52f0202b 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/plate_reading/backend.py @@ -39,23 +39,28 @@ async def close(self, plate: Optional[Plate]) -> None: @abstractmethod async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the luminescence from the plate reader. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key (0, 0) - and a value containing the data, temperature, and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "time": float, + "temperature": float, + "data": List[List[float]] """ @abstractmethod async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the absorbance from the plate reader. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key (wavelength, 0) - and a value containing the data, temperature, and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] """ @abstractmethod @@ -66,13 +71,16 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the fluorescence from the plate reader. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key - (excitation_wavelength, emission_wavelength) and a value containing the data, temperature, - and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "ex_wavelength": int, + "em_wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] """ diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py index 0ba00e7afea..f73cb60244f 100644 --- a/pylabrobot/plate_reading/biotek_backend.py +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -701,7 +701,7 @@ def _get_min_max_row_col_tuples( async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: if not 230 <= wavelength <= 999: raise ValueError("Wavelength must be between 230 and 999") @@ -739,17 +739,16 @@ async def read_absorbance( return [ { - (wavelength, 0): { - "data": all_data, - "temp": temp, - "time": time.time(), - } + "wavelength": wavelength, + "data": all_data, + "temperature": temp, + "time": time.time(), } ] async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: if not 4.5 <= focal_height <= 13.88: raise ValueError("Focal height must be between 4.5 and 13.88") @@ -801,11 +800,9 @@ async def read_luminescence( return [ { - (0, 0): { # Luminescence does not have excitation/emission wavelength in the same way - "data": all_data, - "temp": temp, - "time": time.time(), - } + "data": all_data, + "temperature": temp, + "time": time.time(), } ] @@ -816,7 +813,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: if not 4.5 <= focal_height <= 13.88: raise ValueError("Focal height must be between 4.5 and 13.88") if not 250 <= excitation_wavelength <= 700: @@ -864,11 +861,11 @@ async def read_fluorescence( return [ { - (excitation_wavelength, emission_wavelength): { - "data": all_data, - "temp": temp, - "time": time.time(), - } + "ex_wavelength": excitation_wavelength, + "em_wavelength": emission_wavelength, + "data": all_data, + "temperature": temp, + "time": time.time(), } ] diff --git a/pylabrobot/plate_reading/biotek_tests.py b/pylabrobot/plate_reading/biotek_tests.py index 68b2494ac0f..939d99a17f3 100644 --- a/pylabrobot/plate_reading/biotek_tests.py +++ b/pylabrobot/plate_reading/biotek_tests.py @@ -184,14 +184,26 @@ async def test_read_absorbance(self): 0.0707, 0.1649, ], + [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], ] - self.assertEqual(resp, [{(580, 0): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}]) + self.assertEqual( + resp, + [ + { + "wavelength": 580, + "data": expected_data, + "temperature": 23.6, + "time": 12345.6789, + } + ], + ) async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( # plate + "\x06" + "\x03" # focal height @@ -243,7 +255,16 @@ async def test_read_luminescence_partial(self): [0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None], [None, None, None, None, None, None, None, None, None, None, None, None], ] - self.assertEqual(resp, [{(0, 0): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}]) + self.assertEqual( + resp, + [ + { + "data": expected_data, + "temperature": 23.6, + "time": 12345.6789, + } + ], + ) async def test_read_fluorescence(self): self.backend.io.read.side_effect = _byte_iter( @@ -305,5 +326,14 @@ async def test_read_fluorescence(self): [1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0], ] self.assertEqual( - resp, [{(485, 528): {"data": expected_data, "temp": 23.6, "time": 12345.6789}}] + resp, + [ + { + "ex_wavelength": 485, + "em_wavelength": 528, + "data": expected_data, + "temperature": 23.6, + "time": 12345.6789 + } + ] ) diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py index e105e4e57c2..f674e6c1164 100644 --- a/pylabrobot/plate_reading/chatterbox.py +++ b/pylabrobot/plate_reading/chatterbox.py @@ -71,34 +71,35 @@ def _mask_result( masked[r][c] = result[r][c] return masked - def _format_data( - self, data: List[List[Optional[float]]], key: Tuple[int, int] - ) -> List[Dict[Tuple[int, int], Dict]]: - return [ - { - key: { - "data": data, - "temp": float("nan"), - "time": time.time(), - } - } - ] - async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: print(f"Reading luminescence at focal height {focal_height}.") result = self._mask_result(self.dummy_luminescence, wells, plate) self._print_plate_reading_wells(result) - return self._format_data(result, (0, 0)) + return [ + { + "time": time.time(), + "temperature": float("nan"), + "data": result, + "em_wavelength": 0, + } + ] async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: print(f"Reading absorbance at wavelength {wavelength}.") result = self._mask_result(self.dummy_absorbance, wells, plate) self._print_plate_reading_wells(result) - return self._format_data(result, (wavelength, 0)) + return [ + { + "time": time.time(), + "temperature": float("nan"), + "data": result, + "wavelength": wavelength, + } + ] async def read_fluorescence( self, @@ -107,10 +108,18 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: print( f"Reading fluorescence at excitation wavelength {excitation_wavelength}, emission wavelength {emission_wavelength}, and focal height {focal_height}." ) result = self._mask_result(self.dummy_fluorescence, wells, plate) self._print_plate_reading_wells(result) - return self._format_data(result, (excitation_wavelength, emission_wavelength)) + return [ + { + "time": time.time(), + "temperature": float("nan"), + "data": result, + "ex_wavelength": excitation_wavelength, + "em_wavelength": emission_wavelength, + } + ] diff --git a/pylabrobot/plate_reading/clario_star_backend.py b/pylabrobot/plate_reading/clario_star_backend.py index 4caa75247e5..21ab5526c87 100644 --- a/pylabrobot/plate_reading/clario_star_backend.py +++ b/pylabrobot/plate_reading/clario_star_backend.py @@ -259,7 +259,7 @@ async def _get_measurement_values(self): async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float = 13 - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read luminescence values from the plate reader.""" if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") @@ -293,11 +293,9 @@ async def read_luminescence( return [ { - (0, 0): { - "data": floats, - "temp": float("nan"), # Temperature not available - "time": time.time(), - } + "data": floats, + "temperature": float("nan"), # Temperature not available + "time": time.time(), } ] @@ -307,7 +305,7 @@ async def read_absorbance( wells: List[Well], wavelength: int, report: Literal["OD", "transmittance"] = "OD", - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read absorbance values from the device. Args: @@ -373,11 +371,10 @@ async def read_absorbance( return [ { - (wavelength, 0): { - "data": data, - "temp": float("nan"), # Temperature not available - "time": time.time(), - } + "wavelength": wavelength, + "data": data, + "temperature": float("nan"), # Temperature not available + "time": time.time(), } ] diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index cf1d040e0d5..92738f872f7 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -360,7 +360,7 @@ async def _read_now(self) -> None: async def _transfer_data( self, settings: MolecularDevicesSettings - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each reading and combine them into a single collection. """ @@ -380,29 +380,17 @@ async def _transfer_data( res = await self.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) - all_reads.append(read_data) - - if settings.read_type == ReadType.SPECTRUM: - # Combine data for spectrum reads - combined_spectrum = {} - for read_data in all_reads: - for key, value in read_data.items(): - combined_spectrum[key] = { - "data": value["data"], - "temp": value["temp"], - "time": value["time"], - } - return [combined_spectrum] # Return as a list with one element - return all_reads # For KINETIC + all_reads.extend(read_data) # Unpack the list + return all_reads # For ENDPOINT res = await self.send_command("!TRANSFER") data_str = res[1] - return [self._parse_data(data_str, settings)] + return self._parse_data(data_str, settings) def _parse_data( self, data_str: str, settings: MolecularDevicesSettings - ) -> Dict[Tuple[int, int], Dict]: + ) -> List[Dict]: lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] @@ -460,29 +448,28 @@ def _parse_data( data_rows.append(row) data_collection_transposed.append(data_rows) - timepoint_data = {} + measurements = [] read_mode = settings.read_mode for i, data_rows in enumerate(data_collection_transposed): - key = None + measurement = { + "data": data_rows, + "temperature": temperature, + "time": measurement_time, + } if read_mode == ReadMode.ABS: wl = int(cur_read_wavelengths[i][0]) - key = (wl, 0) + measurement["wavelength"] = wl elif read_mode == ReadMode.FLU or read_mode == ReadMode.POLAR or read_mode == ReadMode.TIME: ex_wl = int(cur_read_wavelengths[i][0]) em_wl = int(cur_read_wavelengths[i][1]) - key = (ex_wl, em_wl) + measurement["ex_wavelength"] = ex_wl + measurement["em_wavelength"] = em_wl elif read_mode == ReadMode.LUM: em_wl = int(cur_read_wavelengths[i][1]) - key = (0, em_wl) + measurement["em_wavelength"] = em_wl + measurements.append(measurement) - if key: - timepoint_data[key] = { - "data": data_rows, - "temp": temperature, - "time": measurement_time, - } - - return timepoint_data + return measurements async def _set_clear(self) -> None: await self.send_command("!CLEAR DATA") @@ -709,7 +696,7 @@ async def read_absorbance( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.ABS, @@ -765,7 +752,7 @@ async def read_fluorescence( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" settings = MolecularDevicesSettings( plate=plate, @@ -827,7 +814,7 @@ async def read_luminescence( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.LUM, @@ -888,7 +875,7 @@ async def read_fluorescence_polarization( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.POLAR, @@ -953,7 +940,7 @@ async def read_time_resolved_fluorescence( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.TIME, diff --git a/pylabrobot/plate_reading/molecular_devices_backend_tests.py b/pylabrobot/plate_reading/molecular_devices_backend_tests.py index 79ee1913173..de0d83bdc89 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend_tests.py +++ b/pylabrobot/plate_reading/molecular_devices_backend_tests.py @@ -706,13 +706,14 @@ def test_parse_absorbance_single_wavelength(self): kinetic_settings=None, spectrum_settings=None, ) + result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, dict) + self.assertIsInstance(result, list) self.assertEqual(len(result), 1) - self.assertIn((260, 0), result) - read = result[(260, 0)] + read = result[0] + self.assertEqual(read["wavelength"], 260) self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["temperature"], 25.1) self.assertEqual(read["data"], [[0.1, 0.3], [0.2, 0.4]]) def test_parse_absorbance_multiple_wavelengths(self): @@ -739,14 +740,12 @@ def test_parse_absorbance_multiple_wavelengths(self): spectrum_settings=None, ) result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, dict) + self.assertIsInstance(result, list) self.assertEqual(len(result), 2) - self.assertIn((260, 0), result) - read1 = result[(260, 0)] - self.assertEqual(read1["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertIn((280, 0), result) - read2 = result[(280, 0)] - self.assertEqual(read2["data"], [[0.5, 0.7], [0.6, 0.8]]) + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[1]["wavelength"], 280) + self.assertEqual(result[1]["data"], [[0.5, 0.7], [0.6, 0.8]]) def test_parse_fluorescence(self): data_str = """ @@ -770,12 +769,13 @@ def test_parse_fluorescence(self): spectrum_settings=None, ) result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, dict) + self.assertIsInstance(result, list) self.assertEqual(len(result), 1) - self.assertIn((485, 520), result) - read = result[(485, 520)] + read = result[0] + self.assertEqual(read["ex_wavelength"], 485) + self.assertEqual(read["em_wavelength"], 520) self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["temperature"], 25.1) self.assertEqual(read["data"], [[100.0, 300.0], [200.0, 400.0]]) def test_parse_luminescence(self): @@ -799,12 +799,12 @@ def test_parse_luminescence(self): spectrum_settings=None, ) result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, dict) + self.assertIsInstance(result, list) self.assertEqual(len(result), 1) - self.assertIn((0, 590), result) - read = result[(0, 590)] + read = result[0] + self.assertEqual(read["em_wavelength"], 590) self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temp"], 25.1) + self.assertEqual(read["temperature"], 25.1) self.assertEqual(read["data"], [[1000.0, 3000.0], [2000.0, 4000.0]]) def test_parse_data_with_sat_and_nan(self): @@ -828,7 +828,9 @@ def test_parse_data_with_sat_and_nan(self): spectrum_settings=None, ) result = self.backend._parse_data(data_str, settings) - read = result[(260, 0)] + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + read = result[0] self.assertEqual(read["data"][1][0], float("inf")) self.assertTrue(math.isnan(read["data"][1][1])) @@ -873,10 +875,12 @@ def data_generator(): result = await self.backend._transfer_data(settings) self.assertEqual(len(result), 2) - self.assertEqual(result[0][(260, 0)]["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(result[1][(260, 0)]["data"], [[0.15, 0.35], [0.25, 0.45]]) - self.assertEqual(result[0][(260, 0)]["time"], 12345.6) - self.assertEqual(result[1][(260, 0)]["time"], 12355.6) + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[0]["time"], 12345.6) + self.assertEqual(result[1]["wavelength"], 260) + self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result[1]["time"], 12355.6) async def test_parse_spectrum_absorbance(self): # Mock the send_command to return two different data blocks for two wavelengths @@ -918,17 +922,15 @@ def data_generator(): ) result = await self.backend._transfer_data(settings) - self.assertEqual(len(result), 1) # Should return a list with one dict - combined_data = result[0] - self.assertEqual(len(combined_data), 2) # Two wavelengths + self.assertEqual(len(result), 2) - self.assertIn((260, 0), combined_data) - self.assertEqual(combined_data[(260, 0)]["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(combined_data[(260, 0)]["time"], 12345.6) + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[0]["time"], 12345.6) - self.assertIn((270, 0), combined_data) - self.assertEqual(combined_data[(270, 0)]["data"], [[0.15, 0.35], [0.25, 0.45]]) - self.assertEqual(combined_data[(270, 0)]["time"], 12355.6) + self.assertEqual(result[1]["wavelength"], 270) + self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result[1]["time"], 12355.6) class TestErrorHandling(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index fea84566b68..8424fb6fca9 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -52,7 +52,7 @@ async def read_fluorescence( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: raise NotImplementedError("Fluorescence reading is not supported.") async def read_luminescence( # type: ignore[override] @@ -72,7 +72,7 @@ async def read_luminescence( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: raise NotImplementedError("Luminescence reading is not supported.") async def read_fluorescence_polarization( @@ -94,7 +94,7 @@ async def read_fluorescence_polarization( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: raise NotImplementedError("Fluorescence polarization reading is not supported.") async def read_time_resolved_fluorescence( @@ -118,5 +118,5 @@ async def read_time_resolved_fluorescence( cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: raise NotImplementedError("Time-resolved fluorescence reading is not supported.") diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index c2d2b049062..36a7f33af21 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -77,15 +77,17 @@ async def close(self, **backend_kwargs) -> None: @need_setup_finished async def read_luminescence( self, focal_height: float, wells: Optional[List[Well]] = None, **backend_kwargs - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the luminescence from the plate reader. Args: focal_height: The focal height to read the luminescence at, in micrometers. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key (0, 0) - and a value containing the data, temperature, and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "time": float, + "temperature": float, + "data": List[List[float]] """ return await self.backend.read_luminescence( @@ -98,15 +100,18 @@ async def read_luminescence( @need_setup_finished async def read_absorbance( self, wavelength: int, wells: Optional[List[Well]] = None, **backend_kwargs - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the absorbance from the plate reader. Args: wavelength: The wavelength to read the absorbance at, in nanometers. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key (wavelength, 0) - and a value containing the data, temperature, and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] """ return await self.backend.read_absorbance( @@ -124,7 +129,7 @@ async def read_fluorescence( focal_height: float, wells: Optional[List[Well]] = None, **backend_kwargs, - ) -> List[Dict[Tuple[int, int], Dict]]: + ) -> List[Dict]: """Read the fluorescence from the plate reader. Args: @@ -133,9 +138,12 @@ async def read_fluorescence( focal_height: The focal height to read the fluorescence at, in micrometers. Returns: - A list of dictionaries, one for each timepoint. Each dictionary has a key - (excitation_wavelength, emission_wavelength) and a value containing the data, temperature, - and time. + A list of dictionaries, one for each measurement. Each dictionary contains: + "ex_wavelength": int, + "em_wavelength": int, + "time": float, + "temperature": float, + "data": List[List[float]] """ if excitation_wavelength > emission_wavelength: From 734404c55edf010e520943e3cf52c2efe9181b9c Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Sat, 8 Nov 2025 20:20:33 -0800 Subject: [PATCH 17/18] format and minor fix --- pylabrobot/plate_reading/backend.py | 6 +-- pylabrobot/plate_reading/biotek_backend.py | 4 +- pylabrobot/plate_reading/biotek_tests.py | 6 +-- pylabrobot/plate_reading/chatterbox.py | 6 +-- .../molecular_devices_backend.py | 10 ++--- ...lar_devices_spectramax_384_plus_backend.py | 2 +- pylabrobot/plate_reading/plate_reader.py | 41 ++++++++++++++++--- 7 files changed, 46 insertions(+), 29 deletions(-) diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/plate_reading/backend.py index 48d52f0202b..f793e18a023 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/plate_reading/backend.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional from pylabrobot.machines.backend import MachineBackend from pylabrobot.plate_reading.standard import ( @@ -50,9 +50,7 @@ async def read_luminescence( """ @abstractmethod - async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict]: + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: """Read the absorbance from the plate reader. Returns: diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py index f73cb60244f..f333a588524 100644 --- a/pylabrobot/plate_reading/biotek_backend.py +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -699,9 +699,7 @@ def _get_min_max_row_col_tuples( raise ValueError("All wells must be in the specified plate") return _non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells) - async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict]: + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: if not 230 <= wavelength <= 999: raise ValueError("Wavelength must be between 230 and 999") diff --git a/pylabrobot/plate_reading/biotek_tests.py b/pylabrobot/plate_reading/biotek_tests.py index 939d99a17f3..b4d6a167a1c 100644 --- a/pylabrobot/plate_reading/biotek_tests.py +++ b/pylabrobot/plate_reading/biotek_tests.py @@ -184,7 +184,6 @@ async def test_read_absorbance(self): 0.0707, 0.1649, ], - [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], ] @@ -203,7 +202,6 @@ async def test_read_absorbance(self): async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( # plate - "\x06" + "\x03" # focal height @@ -333,7 +331,7 @@ async def test_read_fluorescence(self): "em_wavelength": 528, "data": expected_data, "temperature": 23.6, - "time": 12345.6789 + "time": 12345.6789, } - ] + ], ) diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py index f674e6c1164..6f4da390abd 100644 --- a/pylabrobot/plate_reading/chatterbox.py +++ b/pylabrobot/plate_reading/chatterbox.py @@ -1,5 +1,5 @@ import time -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well @@ -86,9 +86,7 @@ async def read_luminescence( } ] - async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int - ) -> List[Dict]: + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: print(f"Reading absorbance at wavelength {wavelength}.") result = self._mask_result(self.dummy_absorbance, wells, plate) self._print_plate_reading_wells(result) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py index 92738f872f7..434a1d5e4ac 100644 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_backend.py @@ -358,9 +358,7 @@ async def stop_shake(self) -> None: async def _read_now(self) -> None: await self.send_command("!READ") - async def _transfer_data( - self, settings: MolecularDevicesSettings - ) -> List[Dict]: + async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each reading and combine them into a single collection. """ @@ -380,7 +378,7 @@ async def _transfer_data( res = await self.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) - all_reads.extend(read_data) # Unpack the list + all_reads.extend(read_data) # Unpack the list return all_reads # For ENDPOINT @@ -388,9 +386,7 @@ async def _transfer_data( data_str = res[1] return self._parse_data(data_str, settings) - def _parse_data( - self, data_str: str, settings: MolecularDevicesSettings - ) -> List[Dict]: + def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List[Dict]: lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] diff --git a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py index 8424fb6fca9..65d40d0875a 100644 --- a/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py +++ b/pylabrobot/plate_reading/molecular_devices_spectramax_384_plus_backend.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, Tuple, Union +from typing import Dict, List, Optional, Union from pylabrobot.resources.plate import Plate diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index 36a7f33af21..db8a01697c0 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, List, Optional, Tuple, cast +from typing import Dict, List, Optional, cast from pylabrobot.machines.machine import Machine, need_setup_finished from pylabrobot.plate_reading.backend import PlateReaderBackend @@ -74,14 +74,16 @@ async def close(self, **backend_kwargs) -> None: plate = self.get_plate() if len(self.children) > 0 else None await self.backend.close(plate=plate, **backend_kwargs) + @need_setup_finished async def read_luminescence( - self, focal_height: float, wells: Optional[List[Well]] = None, **backend_kwargs + self, focal_height: float, wells: Optional[List[Well]] = None, use_new_return_type: bool = False, **backend_kwargs ) -> List[Dict]: """Read the luminescence from the plate reader. Args: focal_height: The focal height to read the luminescence at, in micrometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. Returns: A list of dictionaries, one for each measurement. Each dictionary contains: @@ -90,21 +92,30 @@ async def read_luminescence( "data": List[List[float]] """ - return await self.backend.read_luminescence( + result = await self.backend.read_luminescence( plate=self.get_plate(), wells=wells or self.get_plate().get_all_items(), focal_height=focal_height, **backend_kwargs, ) + if not use_new_return_type: + logger.warning( + "The return type of read_luminescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] + return result + @need_setup_finished async def read_absorbance( - self, wavelength: int, wells: Optional[List[Well]] = None, **backend_kwargs + self, wavelength: int, wells: Optional[List[Well]] = None, use_new_return_type: bool = False, **backend_kwargs ) -> List[Dict]: """Read the absorbance from the plate reader. Args: wavelength: The wavelength to read the absorbance at, in nanometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. Returns: A list of dictionaries, one for each measurement. Each dictionary contains: @@ -114,13 +125,21 @@ async def read_absorbance( "data": List[List[float]] """ - return await self.backend.read_absorbance( + result = await self.backend.read_absorbance( plate=self.get_plate(), wells=wells or self.get_plate().get_all_items(), wavelength=wavelength, **backend_kwargs, ) + if not use_new_return_type: + logger.warning( + "The return type of read_absorbance will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] + return result + @need_setup_finished async def read_fluorescence( self, @@ -128,6 +147,7 @@ async def read_fluorescence( emission_wavelength: int, focal_height: float, wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, **backend_kwargs, ) -> List[Dict]: """Read the fluorescence from the plate reader. @@ -136,6 +156,7 @@ async def read_fluorescence( excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. focal_height: The focal height to read the fluorescence at, in micrometers. + use_new_return_type: Whether to return the new return type, which is a list of dictionaries. Returns: A list of dictionaries, one for each measurement. Each dictionary contains: @@ -151,7 +172,7 @@ async def read_fluorescence( "Excitation wavelength is greater than emission wavelength. This is unusual and may indicate an error." ) - return await self.backend.read_fluorescence( + result = await self.backend.read_fluorescence( plate=self.get_plate(), wells=wells or self.get_plate().get_all_items(), excitation_wavelength=excitation_wavelength, @@ -159,3 +180,11 @@ async def read_fluorescence( focal_height=focal_height, **backend_kwargs, ) + + if not use_new_return_type: + logger.warning( + "The return type of read_fluorescence will change in a future version. Please set " + "use_new_return_type=True to use the new return type." + ) + return result[0]["data"] + return result From 2183c15789db38b0c7e400763a83d8571948542b Mon Sep 17 00:00:00 2001 From: Boqiang Tu Date: Sat, 8 Nov 2025 20:26:19 -0800 Subject: [PATCH 18/18] format --- pylabrobot/plate_reading/plate_reader.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index db8a01697c0..fc78b40a83a 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -74,10 +74,13 @@ async def close(self, **backend_kwargs) -> None: plate = self.get_plate() if len(self.children) > 0 else None await self.backend.close(plate=plate, **backend_kwargs) - @need_setup_finished async def read_luminescence( - self, focal_height: float, wells: Optional[List[Well]] = None, use_new_return_type: bool = False, **backend_kwargs + self, + focal_height: float, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, ) -> List[Dict]: """Read the luminescence from the plate reader. @@ -104,12 +107,16 @@ async def read_luminescence( "The return type of read_luminescence will change in a future version. Please set " "use_new_return_type=True to use the new return type." ) - return result[0]["data"] + return result[0]["data"] # type: ignore[no-any-return] return result @need_setup_finished async def read_absorbance( - self, wavelength: int, wells: Optional[List[Well]] = None, use_new_return_type: bool = False, **backend_kwargs + self, + wavelength: int, + wells: Optional[List[Well]] = None, + use_new_return_type: bool = False, + **backend_kwargs, ) -> List[Dict]: """Read the absorbance from the plate reader. @@ -137,7 +144,7 @@ async def read_absorbance( "The return type of read_absorbance will change in a future version. Please set " "use_new_return_type=True to use the new return type." ) - return result[0]["data"] + return result[0]["data"] # type: ignore[no-any-return] return result @need_setup_finished @@ -186,5 +193,5 @@ async def read_fluorescence( "The return type of read_fluorescence will change in a future version. Please set " "use_new_return_type=True to use the new return type." ) - return result[0]["data"] + return result[0]["data"] # type: ignore[no-any-return] return result