From de9f0a7b3a1512edfb331a0f0ef099d3fefc9445 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Sat, 20 Dec 2025 18:04:24 +0800 Subject: [PATCH 01/33] Fix USB capture decode for malformed escape sequences --- pylabrobot/io/usb.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pylabrobot/io/usb.py b/pylabrobot/io/usb.py index 4438ea807a7..bf831730281 100644 --- a/pylabrobot/io/usb.py +++ b/pylabrobot/io/usb.py @@ -118,7 +118,11 @@ async def write(self, data: bytes, timeout: Optional[float] = None): ) logger.log(LOG_LEVEL_IO, "%s write: %s", self._unique_id, data) capturer.record( - USBCommand(device_id=self._unique_id, action="write", data=data.decode("unicode_escape")) + USBCommand( + device_id=self._unique_id, + action="write", + data=data.decode("unicode_escape", errors="backslashreplace"), + ) ) def _read_packet(self) -> Optional[bytearray]: @@ -180,7 +184,11 @@ def read_or_timeout(): logger.log(LOG_LEVEL_IO, "%s read: %s", self._unique_id, resp) capturer.record( - USBCommand(device_id=self._unique_id, action="read", data=resp.decode("unicode_escape")) + USBCommand( + device_id=self._unique_id, + action="read", + data=resp.decode("unicode_escape", errors="backslashreplace"), + ) ) return resp @@ -414,8 +422,9 @@ async def write(self, data: bytes, timeout: Optional[float] = None): and next_command.action == "write" ): raise ValidationError("next command is not write") - if not next_command.data == data.decode("unicode_escape"): - align_sequences(expected=next_command.data, actual=data.decode("unicode_escape")) + decoded = data.decode("unicode_escape", errors="backslashreplace") + if not next_command.data == decoded: + align_sequences(expected=next_command.data, actual=decoded) raise ValidationError("Data mismatch: difference was written to stdout.") async def read(self, timeout: Optional[float] = None) -> bytes: From 1e61aae6cfcc986d8992f6c8fb082a14b21ec088 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Sat, 20 Dec 2025 18:04:49 +0800 Subject: [PATCH 02/33] Add Tecan Infinite backend and tests --- pylabrobot/plate_reading/__init__.py | 1 + .../plate_reading/tecan_infinite_backend.py | 1041 +++++++++++++++++ .../tecan_infinite_backend_tests.py | 598 ++++++++++ 3 files changed, 1640 insertions(+) create mode 100644 pylabrobot/plate_reading/tecan_infinite_backend.py create mode 100644 pylabrobot/plate_reading/tecan_infinite_backend_tests.py diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index a0018232632..a8021bc5204 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -12,3 +12,4 @@ ImagingResult, Objective, ) +from .tecan_infinite_backend import TecanInfinite200ProBackend, TecanScanConfig diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py new file mode 100644 index 00000000000..a3794b4e126 --- /dev/null +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -0,0 +1,1041 @@ +"""Tecan Infinite 200 PRO backend. + +This backend targets the Infinite "M" series (e.g., Infinite 200 PRO). The +"F" series uses a different optical path and is not covered here. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, List, Optional, Protocol, Sequence, Tuple + +from pylabrobot.io.usb import USB +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate +from pylabrobot.resources.well import Well + +logger = logging.getLogger(__name__) + + +class InfiniteTransport(Protocol): + """Minimal transport required by the backend. + + Implementations are expected to wrap PyUSB/libusbK. + """ + + async def open(self) -> None: + ... + + async def close(self) -> None: + ... + + async def write(self, data: bytes) -> None: + ... + + async def read(self, size: int) -> bytes: + ... + + +class PyUSBInfiniteTransport(InfiniteTransport): + """Transport that reuses pylabrobot.io.usb.USB for Infinite communication.""" + + def __init__( + self, + vendor_id: int = 0x0C47, + product_id: int = 0x8007, + packet_read_timeout: int = 3, + read_timeout: int = 30, + ) -> None: + self._vendor_id = vendor_id + self._product_id = product_id + self._usb: Optional[USB] = None + self._packet_read_timeout = packet_read_timeout + self._read_timeout = read_timeout + + async def open(self) -> None: + io = USB( + id_vendor=self._vendor_id, + id_product=self._product_id, + packet_read_timeout=self._packet_read_timeout, + read_timeout=self._read_timeout, + ) + await io.setup() + self._usb = io + + async def close(self) -> None: + if self._usb is not None: + await self._usb.stop() + self._usb = None + + async def write(self, data: bytes) -> None: + if self._usb is None or self._usb.write_endpoint is None: + raise RuntimeError("USB transport not opened.") + await self._usb.write(data) + + async def read(self, size: int) -> bytes: + if self._usb is None: + raise RuntimeError("USB transport not opened.") + data = await self._usb.read() + b = bytes(data[:size]) + return b + + +@dataclass +class InfiniteScanConfig: + flashes: int = 25 + counts_per_mm_x: float = 1_000 + counts_per_mm_y: float = 1_000 + + +TecanScanConfig = InfiniteScanConfig + + +def _be16_words(payload: bytes) -> List[int]: + return [int.from_bytes(payload[i : i + 2], "big") for i in range(0, len(payload), 2)] + + +StagePosition = Tuple[int, int] + + +def _consume_leading_ascii_frame(buffer: bytearray) -> Tuple[bool, Optional[str]]: + """Remove a leading STX...ETX ASCII frame if present.""" + + if not buffer or buffer[0] != 0x02: + return False, None + end = buffer.find(b"\x03", 1) + if end == -1: + return False, None + text = buffer[1:end].decode("ascii", "ignore") + del buffer[: end + 2] + if buffer and buffer[0] == 0x0D: + del buffer[0] + return True, text + + +def _consume_status_frame(buffer: bytearray, length: int) -> bool: + """Drop a leading ESC-prefixed status frame if present.""" + + if len(buffer) >= length and buffer[0] == 0x1B: + del buffer[:length] + return True + return False + + +class _MeasurementDecoder(ABC): + """Shared incremental decoder for Infinite measurement streams.""" + + STATUS_FRAME_LEN: Optional[int] = None + + def __init__(self, expected: int) -> None: + self.expected = expected + self._buffer: bytearray = bytearray() + self._terminal_seen = False + + @property + @abstractmethod + def count(self) -> int: + """Return number of decoded measurements so far.""" + + @property + def done(self) -> bool: + return self.count >= self.expected + + def pop_terminal(self) -> bool: + seen = self._terminal_seen + self._terminal_seen = False + return seen + + def feed(self, chunk: bytes) -> None: + self._buffer.extend(chunk) + progressed = True + while progressed: + progressed = False + consumed, text = _consume_leading_ascii_frame(self._buffer) + if consumed: + if text == "ST": + self._terminal_seen = True + progressed = True + continue + if not self.done and self._consume_measurement(): + progressed = True + continue + if self.STATUS_FRAME_LEN and _consume_status_frame(self._buffer, self.STATUS_FRAME_LEN): + progressed = True + continue + if self.done or not self._buffer: + break + progressed = self._discard_byte() + + @abstractmethod + def _consume_measurement(self) -> bool: + """Attempt to consume a measurement frame from the buffer.""" + + def _discard_byte(self) -> bool: + if self._buffer: + del self._buffer[0] + return True + return False + + +class TecanInfinite200ProBackend(PlateReaderBackend): + """Backend shell for the Infinite 200 PRO.""" + + _MODE_CAPABILITY_COMMANDS: Dict[str, List[str]] = { + "ABS": [ + "#BEAM DIAMETER", + # Additional capabilities available but currently unused: + # "#EXCITATION WAVELENGTH", + # "#EXCITATION USAGE", + # "#EXCITATION NAME", + # "#EXCITATION BANDWIDTH", + # "#EXCITATION ATTENUATION", + # "#EXCITATION DESCRIPTION", + # "#TIME READDELAY", + # "#SHAKING MODE", + # "#SHAKING CONST.ORBITAL", + # "#SHAKING AMPLITUDE", + # "#SHAKING TIME", + # "#SHAKING CONST.LINEAR", + # "#TEMPERATURE PLATE", + ], + "FI.TOP": [ + "#BEAM DIAMETER", + # Additional capabilities available but currently unused: + # "#EMISSION WAVELENGTH", + # "#EMISSION USAGE", + # "#EMISSION NAME", + # "#EMISSION BANDWIDTH", + # "#EMISSION ATTENUATION", + # "#EMISSION DESCRIPTION", + # "#EXCITATION WAVELENGTH", + # "#EXCITATION USAGE", + # "#EXCITATION NAME", + # "#EXCITATION BANDWIDTH", + # "#EXCITATION ATTENUATION", + # "#EXCITATION DESCRIPTION", + # "#TIME INTEGRATION", + # "#TIME LAG", + # "#TIME READDELAY", + # "#GAIN VALUE", + # "#READS SPEED", + # "#READS NUMBER", + # "#RANGES PMT,EXCITATION", + # "#RANGES PMT,EMISSION", + # "#POSITION FIL,Z", + # "#TEMPERATURE PLATE", + ], + "FI.BOTTOM": [ + "#BEAM DIAMETER", + # Additional capabilities available but currently unused: + # "#EMISSION WAVELENGTH", + # "#EMISSION USAGE", + # "#EXCITATION WAVELENGTH", + # "#EXCITATION USAGE", + # "#TIME INTEGRATION", + # "#TIME LAG", + # "#TIME READDELAY", + ], + "LUM": [ + "#BEAM DIAMETER", + # Additional capabilities available but currently unused: + # "#EMISSION WAVELENGTH", + # "#EMISSION USAGE", + # "#EMISSION NAME", + # "#EMISSION BANDWIDTH", + # "#EMISSION ATTENUATION", + # "#EMISSION DESCRIPTION", + # "#TIME INTEGRATION", + # "#TIME READDELAY", + ], + } + + def __init__( + self, + transport: Optional[InfiniteTransport] = None, + scan_config: Optional[InfiniteScanConfig] = None, + ) -> None: + self._transport = transport or PyUSBInfiniteTransport() + self.config = scan_config or InfiniteScanConfig() + self._setup_lock = asyncio.Lock() + self._ready = False + self._read_chunk_size = 512 + self._max_read_iterations = 200 + self._device_initialized = False + self._mode_capabilities: Dict[str, Dict[str, str]] = {} + self._current_fluorescence_excitation: Optional[int] = None + self._current_fluorescence_emission: Optional[int] = None + + async def setup(self) -> None: + async with self._setup_lock: + if self._ready: + return + await self._transport.open() + await self._initialize_device() + for mode in self._MODE_CAPABILITY_COMMANDS: + if mode not in self._mode_capabilities: + await self._query_mode_capabilities(mode) + self._ready = True + + async def stop(self) -> None: + async with self._setup_lock: + if not self._ready: + return + await self._transport.close() + self._ready = False + + async def open(self) -> None: + """Open the reader drawer.""" + + await self._send_ascii("ABSOLUTE MTP,OUT") + await self._send_ascii("BY#T5000") + + async def close(self, plate: Optional[Plate]) -> None: # noqa: ARG002 + """Close the reader drawer.""" + + await self._send_ascii("ABSOLUTE MTP,IN") + await self._send_ascii("BY#T5000") + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + """Queue and execute an absorbance scan.""" + + if not 230 <= wavelength <= 1_000: + raise ValueError("Absorbance wavelength must be between 230 nm and 1000 nm.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + + await self._begin_run() + try: + wl_decitenth = int(round(wavelength * 10)) + decoder = _AbsorbanceRunDecoder(len(scan_wells), wl_decitenth) + await self._configure_absorbance(wavelength) + + for row_index, row_wells in self._group_by_row(ordered_wells): + start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) + _, y_stage = self._map_well_to_stage(row_wells[0]) + + await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_ascii("SCAN DIRECTION=ALTUP") + await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + logger.info( + "Queued scan row %s (%s wells): y=%s, x=%s..%s", + row_index, + count, + y_stage, + start_x, + end_x, + ) + await self._await_measurements(decoder, count, "Absorbance") + await self._await_scan_terminal(decoder.pop_terminal()) + + if len(decoder.measurements) != len(scan_wells): + raise RuntimeError("Absorbance decoder did not complete scan.") + intensities = [ + self._calculate_absorbance_od(meas.sample, meas.reference) for meas in decoder.measurements + ] + matrix = self._format_plate_result(plate, scan_wells, intensities) + return [ + { + "wavelength": wavelength, + "time": time.time(), + "temperature": None, + "data": matrix, + } + ] + finally: + await self._end_run(["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"]) + + async def _configure_absorbance(self, wavelength_nm: int) -> None: + wl_decitenth = int(round(wavelength_nm * 10)) + bw_decitenth = int(round(self._auto_bandwidth(wavelength_nm) * 10)) + reads_number = 1 + + await self._send_ascii("MODE ABS") + + commands = [ + "EXCITATION CLEAR", + "TIME CLEAR", + "GAIN CLEAR", + "READS CLEAR", + "POSITION CLEAR", + "MIRROR CLEAR", + f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", + f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", + f"READS 0,NUMBER={reads_number}", + f"READS 1,NUMBER={reads_number}", + "TIME 0,READDELAY=0", + "TIME 1,READDELAY=0", + "SCAN DIRECTION=ALTUP", + "#RATIO LABELS", + f"BEAM DIAMETER={self._capability_numeric('ABS', '#BEAM DIAMETER', 700)}", + "RATIO LABELS=1", + "PREPARE REF", + ] + + for cmd in commands: + await self._send_ascii(cmd) + + def _auto_bandwidth(self, wavelength_nm: int) -> float: + """Return bandwidth in nm based on Infinite M specification.""" + + return 9.0 if wavelength_nm > 315 else 5.0 + + @staticmethod + def _calculate_absorbance_od( + sample: int, + reference: int, + ) -> float: + """Return log10(reference / sample) with guard rails around zero.""" + + safe_sample = max(sample, 1) + safe_reference = max(reference, 1) + return float(math.log10(safe_reference / safe_sample)) + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, # noqa: ARG002 + emission_wavelength: int, # noqa: ARG002 + focal_height: float, # noqa: ARG002 + ) -> List[Dict]: + """Queue and execute a fluorescence scan.""" + + if not 230 <= excitation_wavelength <= 850: + raise ValueError("Excitation wavelength must be between 230 nm and 850 nm.") + if not 230 <= emission_wavelength <= 850: + raise ValueError("Emission wavelength must be between 230 nm and 850 nm.") + if focal_height < 0: + raise ValueError("Focal height must be non-negative for fluorescence scans.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + await self._begin_run() + try: + await self._configure_fluorescence(excitation_wavelength, emission_wavelength) + if self._current_fluorescence_excitation is None: + raise RuntimeError("Fluorescence configuration missing excitation wavelength.") + decoder = _FluorescenceRunDecoder( + len(scan_wells), + self._current_fluorescence_excitation, + self._current_fluorescence_emission, + ) + + for row_index, row_wells in self._group_by_row(ordered_wells): + start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) + _, y_stage = self._map_well_to_stage(row_wells[0]) + + await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_ascii("SCAN DIRECTION=UP") + await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + logger.info( + "Queued fluorescence scan row %s (%s wells): y=%s, x=%s..%s", + row_index, + count, + y_stage, + start_x, + end_x, + ) + await self._await_measurements(decoder, count, "Fluorescence") + await self._await_scan_terminal(decoder.pop_terminal()) + + if len(decoder.intensities) != len(scan_wells): + raise RuntimeError("Fluorescence decoder did not complete scan.") + intensities = decoder.intensities + matrix = self._format_plate_result(plate, scan_wells, intensities) + return [ + { + "ex_wavelength": excitation_wavelength, + "em_wavelength": emission_wavelength, + "time": time.time(), + "temperature": None, + "data": matrix, + } + ] + finally: + await self._end_run(["CHECK MTP.STEPLOSS", "CHECK FI.TOP.STEPLOSS", "CHECK FI.STEPLOSS.Z"]) + + async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> None: + ex_decitenth = int(round(excitation_nm * 10)) + em_decitenth = int(round(emission_nm * 10)) + self._current_fluorescence_excitation = ex_decitenth + self._current_fluorescence_emission = em_decitenth + reads_number = 1 + clear_cmds = [ + "MODE FI.TOP", + "READS CLEAR", + "EXCITATION CLEAR", + "EMISSION CLEAR", + "TIME CLEAR", + "GAIN CLEAR", + "POSITION CLEAR", + "MIRROR CLEAR", + ] + configure_cmds = [ + f"EXCITATION 0,FI,{ex_decitenth},50,0", + f"EMISSION 0,FI,{em_decitenth},200,0", + "TIME 0,INTEGRATION=20", + "TIME 0,LAG=0", + "TIME 0,READDELAY=0", + "GAIN 0,VALUE=100", + "POSITION 0,Z=20000", + f"BEAM DIAMETER={self._capability_numeric('FI.TOP', '#BEAM DIAMETER', 3000)}", + "SCAN DIRECTION=UP", + "RATIO LABELS=1", + f"READS 0,NUMBER={reads_number}", + f"EXCITATION 1,FI,{ex_decitenth},50,0", + f"EMISSION 1,FI,{em_decitenth},200,0", + "TIME 1,INTEGRATION=20", + "TIME 1,LAG=0", + "TIME 1,READDELAY=0", + "GAIN 1,VALUE=100", + "POSITION 1,Z=20000", + f"READS 1,NUMBER={reads_number}", + ] + # UI issues the entire FI configuration twice before PREPARE REF. + for _ in range(2): + for cmd in clear_cmds: + await self._send_ascii(cmd) + for cmd in configure_cmds: + await self._send_ascii(cmd) + await self._send_ascii("PREPARE REF") + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, # noqa: ARG002 + ) -> List[Dict]: + """Queue and execute a luminescence scan.""" + + logger.warning("Luminescence path is experimental; decoding is not yet validated.") + if focal_height < 0: + raise ValueError("Focal height must be non-negative for luminescence scans.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) + await self._begin_run() + try: + await self._configure_luminescence() + decoder = _LuminescenceRunDecoder(len(scan_wells)) + + for row_index, row_wells in self._group_by_row(ordered_wells): + start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=False) + _, y_stage = self._map_well_to_stage(row_wells[0]) + + await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_ascii("SCAN DIRECTION=UP") + await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + logger.info( + "Queued luminescence scan row %s (%s wells): y=%s, x=%s..%s", + row_index, + count, + y_stage, + start_x, + end_x, + ) + await self._await_measurements(decoder, count, "Luminescence") + await self._await_scan_terminal(decoder.pop_terminal()) + + if len(decoder.measurements) != len(scan_wells): + raise RuntimeError("Luminescence decoder did not complete scan.") + intensities = [measurement.intensity for measurement in decoder.measurements] + matrix = self._format_plate_result(plate, scan_wells, intensities) + return [ + { + "time": time.time(), + "temperature": None, + "data": matrix, + } + ] + finally: + await self._end_run(["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"]) + + async def _await_measurements( + self, decoder: "_MeasurementDecoder", row_count: int, mode: str + ) -> None: + target = decoder.count + row_count + iterations = 0 + while decoder.count < target and iterations < self._max_read_iterations: + chunk = await self._transport.read(self._read_chunk_size) + if not chunk: + raise RuntimeError(f"{mode} read returned empty chunk; transport may not support reads.") + decoder.feed(chunk) + iterations += 1 + if decoder.count < target: + raise RuntimeError(f"Timed out while parsing {mode.lower()} results.") + + async def _await_scan_terminal(self, saw_terminal: bool) -> None: + if saw_terminal: + return + await self._read_ascii_response() + + async def _configure_luminescence(self) -> None: + await self._send_ascii("MODE LUM") + # Pre-flight safety checks observed in captures (queries omitted). + await self._send_ascii("CHECK LUM.FIBER") + await self._send_ascii("CHECK LUM.LID") + await self._send_ascii("CHECK LUM.STEPLOSS") + await self._send_ascii("MODE LUM") + commands = [ + "READS CLEAR", + "EMISSION CLEAR", + "TIME CLEAR", + "GAIN CLEAR", + "POSITION CLEAR", + "MIRROR CLEAR", + "POSITION LUM,Z=14620", + "TIME 0,INTEGRATION=3000000", + "SCAN DIRECTION=UP", + "RATIO LABELS=1", + "EMISSION 1,EMPTY,0,0,0", + "TIME 1,INTEGRATION=1000000", + "TIME 1,READDELAY=0", + "#EMISSION ATTENUATION", + "PREPARE REF", + ] + for cmd in commands: + await self._send_ascii(cmd) + + def _group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: + grouped: Dict[int, List[Well]] = {} + for well in wells: + grouped.setdefault(well.get_row(), []).append(well) + for row in grouped.values(): + row.sort(key=lambda w: w.get_column()) + return sorted(grouped.items(), key=lambda item: item[0]) + + def _scan_visit_order(self, wells: Sequence[Well], serpentine: bool) -> List[Well]: + visit: List[Well] = [] + for row_index, row_wells in self._group_by_row(wells): + if serpentine and row_index % 2 == 1: + visit.extend(reversed(row_wells)) + else: + visit.extend(row_wells) + return visit + + def _map_well_to_stage(self, well: Well) -> StagePosition: + if well.location is None: + raise ValueError("Well does not have a location assigned within its plate definition.") + center = well.location + well.get_anchor(x="c", y="c") + cfg = self.config + stage_x = int(round(center.x * cfg.counts_per_mm_x)) + parent_plate = well.parent + if parent_plate is None or not isinstance(parent_plate, Plate): + raise ValueError("Well is not assigned to a plate; cannot derive stage coordinates.") + plate_height_mm = parent_plate.get_size_y() + stage_y = int(round((plate_height_mm - center.y) * cfg.counts_per_mm_y)) + return stage_x, stage_y + + def _scan_range( + self, row_index: int, row_wells: Sequence[Well], serpentine: bool + ) -> Tuple[int, int, int]: + """Return start/end/count for a row, honoring serpentine layout when requested.""" + + first_x, _ = self._map_well_to_stage(row_wells[0]) + last_x, _ = self._map_well_to_stage(row_wells[-1]) + count = len(row_wells) + if not serpentine: + return min(first_x, last_x), max(first_x, last_x), count + if row_index % 2 == 0: + return first_x, last_x, count + return last_x, first_x, count + + def _format_plate_result( + self, plate: Plate, wells: Sequence[Well], values: Sequence[float] + ) -> List[List[Optional[float]]]: + matrix: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well, val in zip(wells, values): + r, c = well.get_row(), well.get_column() + if 0 <= r < plate.num_items_y and 0 <= c < plate.num_items_x: + matrix[r][c] = float(val) + return matrix + + async def _initialize_device(self) -> None: + if self._device_initialized: + return + try: + await self._send_ascii("QQ") + except TimeoutError: + logger.warning("QQ produced no response; continuing with initialization.") + await self._send_ascii("INIT FORCE") + self._device_initialized = True + + async def _begin_run(self) -> None: + await self._initialize_device() + await self._send_ascii("KEYLOCK ON") + + async def _end_run(self, step_loss_commands: Sequence[str]) -> None: + await self._send_ascii("TERMINATE") + for cmd in step_loss_commands: + await self._send_ascii(cmd) + await self._send_ascii("KEYLOCK OFF") + await self._send_ascii("ABSOLUTE MTP,IN") + + async def _query_mode_capabilities(self, mode: str) -> None: + commands = self._MODE_CAPABILITY_COMMANDS.get(mode) + if not commands: + return + try: + await self._send_ascii(f"MODE {mode}") + except TimeoutError: + logger.warning("Capability MODE %s timed out; continuing without mode capabilities.", mode) + return + collected: Dict[str, str] = {} + for cmd in commands: + try: + frames = await self._send_ascii(cmd) + except TimeoutError: + logger.warning("Capability query '%s' timed out; proceeding with defaults.", cmd) + continue + if frames: + collected[cmd] = frames[-1] + if collected: + self._mode_capabilities[mode] = collected + + def _get_mode_capability(self, mode: str, command: str) -> Optional[str]: + return self._mode_capabilities.get(mode, {}).get(command) + + def _capability_numeric(self, mode: str, command: str, fallback: int) -> int: + resp = self._get_mode_capability(mode, command) + if not resp: + return fallback + token = resp.split("|")[0].split(":")[0].split("~")[0].strip() + if not token: + return fallback + try: + return int(float(token)) + except ValueError: + return fallback + + @staticmethod + def _frame_ascii_command(command: str) -> bytes: + """Return a framed ASCII payload with length/checksum trailer.""" + + payload = command.encode("ascii") + xor = 0 + for byte in payload: + xor ^= byte + checksum = (xor ^ 0x01) & 0xFF + length = len(payload) & 0xFF + return b"\x02" + payload + b"\x03\x00\x00" + bytes([length, checksum]) + b"\x0d" + + async def _send_ascii(self, command: str, wait_for_terminal: bool = True) -> List[str]: + logger.debug("[tecan] >> %s", command) + framed = self._frame_ascii_command(command) + await self._transport.write(framed) + if command.startswith(("#", "?")): + frames = await self._read_ascii_response(require_terminal=False) + return frames + frames = await self._read_ascii_response(require_terminal=wait_for_terminal) + for pkt in frames: + logger.debug("[tecan] << %s", pkt) + return frames + + async def _drain_ascii(self, attempts: int = 4) -> None: + for _ in range(attempts): + data = await self._transport.read(128) + if not data: + break + + async def _read_ascii_response( + self, max_iterations: int = 8, require_terminal: bool = True + ) -> List[str]: + buffer = bytearray() + frames: List[str] = [] + saw_terminal = False + for _ in range(max_iterations): + chunk = await self._transport.read(128) + if not chunk: + break + buffer.extend(chunk) + decoded = self._decode_ascii_frames(buffer) + if decoded: + frames.extend(decoded) + if not require_terminal: + break + if any(self._is_terminal_frame(text) for text in decoded): + saw_terminal = True + break + continue + if buffer and all(32 <= b <= 126 for b in buffer): + text = "" + try: + text = buffer.decode("ascii", "ignore") + frames.append(text) + except Exception: + pass + buffer.clear() + if self._is_terminal_frame(text): + saw_terminal = True + break + continue + if require_terminal and not saw_terminal: + # best effort: drain once more so pending ST doesn't leak into next command + await self._drain_ascii(1) + return frames + + @staticmethod + def _is_terminal_frame(text: str) -> bool: + return text in {"ST", "+", "-"} or text.startswith("BY#T") + + @staticmethod + def _decode_ascii_frames(data: bytearray) -> List[str]: + frames: List[str] = [] + while True: + try: + stx = data.index(0x02) + except ValueError: + data.clear() + break + if stx > 0: + del data[:stx] + try: + etx = data.index(0x03, 1) + except ValueError: + break + trailer_len = 4 if len(data) >= etx + 5 else 0 + frame_end = etx + 1 + trailer_len + if len(data) < frame_end: + break + payload = data[1:etx] + try: + frames.append(payload.decode("ascii", "ignore")) + except Exception: + frames.append(payload.hex()) + del data[:frame_end] + if data and data[0] == 0x0D: + del data[0] + return frames + + +@dataclass +class _AbsorbanceMeasurement: + sample: int + reference: int + + +class _AbsorbanceRunDecoder(_MeasurementDecoder): + """Incrementally decode absorbance measurement frames.""" + + STATUS_FRAME_LEN = 31 + _MEAS_LEN = 18 + + def __init__(self, expected: int, wavelength_decitenth: int, skip_initial: int = 0) -> None: + super().__init__(expected) + self._wavelength = wavelength_decitenth + self.measurements: List[_AbsorbanceMeasurement] = [] + self._skip_initial = max(0, skip_initial) + + @property + def count(self) -> int: + return len(self.measurements) + + def _consume_measurement(self) -> bool: + frame = self._find_measurement_frame() + if frame is None: + return False + offset, length = frame + if offset: + del self._buffer[:offset] + payload = bytes(self._buffer[:length]) + del self._buffer[:length] + self._handle_measurement(payload) + return True + + def _handle_measurement(self, payload: bytes) -> None: + words = _be16_words(payload) + if len(words) != 9: + return + if not self._words_match_measurement(words): + return + meas = _AbsorbanceMeasurement(sample=words[5], reference=words[6]) + if self._skip_initial > 0: + self._skip_initial -= 1 + return + self.measurements.append(meas) + + def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: + limit = len(self._buffer) - self._MEAS_LEN + for offset in range(0, limit + 1, 2): + chunk = self._buffer[offset : offset + self._MEAS_LEN] + words = _be16_words(chunk) + if len(words) == 9 and self._words_match_measurement(words): + return offset, self._MEAS_LEN + return None + + def _words_match_measurement(self, words: List[int]) -> bool: + if len(words) != 9: + return False + if words[0] != 1 or words[2] != 0: + return False + if abs(words[1] - self._wavelength) > 1: + return False + return True + + +class _FluorescenceRunDecoder(_MeasurementDecoder): + """Incrementally decode fluorescence measurement frames from measurement tails.""" + + STATUS_FRAME_LEN = 31 + _MEAS_LEN = 20 + + def __init__( + self, expected_wells: int, excitation_decitenth: int, emission_decitenth: Optional[int] + ) -> None: + super().__init__(expected_wells) + self._excitation = excitation_decitenth + self._emission = emission_decitenth + self._intensities: List[int] = [] + + @property + def count(self) -> int: + return len(self._intensities) + + @property + def intensities(self) -> List[int]: + return self._intensities + + def _consume_measurement(self) -> bool: + frame = self._find_measurement_frame() + if frame: + offset, length = frame + if offset: + del self._buffer[:offset] + tail = bytes(self._buffer[:length]) + del self._buffer[:length] + self._handle_measurement_tail(tail) + return True + calib_len = self._calibration_frame_len() + if calib_len: + del self._buffer[:calib_len] + return True + return False + + def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: + limit = len(self._buffer) - self._MEAS_LEN + for offset in range(0, limit + 1, 2): + chunk = self._buffer[offset : offset + self._MEAS_LEN] + words = _be16_words(chunk) + if len(words) == 10 and self._words_match_measurement(words): + return offset, self._MEAS_LEN + return None + + def _calibration_frame_len(self) -> Optional[int]: + return None + + def _handle_measurement_tail(self, tail: bytes) -> None: + words = _be16_words(tail) + intensity = None + if len(words) == 10 and self._words_match_measurement(words): + intensity = words[6] + if intensity is not None: + self._intensities.append(intensity) + + def _words_match_measurement(self, words: List[int]) -> bool: + if not words: + return False + excit = words[1] + emiss = words[2] + if words[0] != 1: + return False + if abs(excit - self._excitation) > 1: + return False + if self._emission is not None and abs(emiss - self._emission) > 1: + return False + return True + + def _discard_byte(self) -> bool: + if self._buffer: + del self._buffer[0] + return True + return False + + +@dataclass +class _LuminescenceMeasurement: + raw_tail: int + intensity: int + words: List[int] + + +class _LuminescenceRunDecoder(_MeasurementDecoder): + """Incrementally decode luminescence measurement frames.""" + + FRAME_LEN = 45 + _MEAS_LEN = 18 + + def __init__(self, expected: int) -> None: + super().__init__(expected) + self.measurements: List[_LuminescenceMeasurement] = [] + + @property + def count(self) -> int: + return len(self.measurements) + + def _consume_measurement(self) -> bool: + frame = self._find_measurement_frame() + if frame: + offset, length = frame + if offset: + del self._buffer[:offset] + payload = bytes(self._buffer[:length]) + del self._buffer[:length] + self._handle_measurement(payload) + return True + return False + + def _discard_byte(self) -> bool: + if self._buffer and self._buffer[0] not in (0x02, 0x1B): + del self._buffer[0] + return True + return False + + def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: + limit = len(self._buffer) - self._MEAS_LEN + for offset in range(0, limit + 1, 2): + chunk = self._buffer[offset : offset + self._MEAS_LEN] + words = _be16_words(chunk) + if len(words) == 9 and self._words_match_measurement(words): + return offset, self._MEAS_LEN + return None + + def _handle_measurement(self, payload: bytes) -> None: + words = _be16_words(payload) + if len(words) != 9: + return + if not self._words_match_measurement(words): + return + raw_tail = payload[-1] if payload else 0 + # Unconfirmed: using words[6] as luminescence intensity; not validated against OEM exports due to lack of proper glowing sample. + intensity = words[6] + self.measurements.append( + _LuminescenceMeasurement( + raw_tail=raw_tail, + intensity=intensity, + words=words, + ) + ) + + def _words_match_measurement(self, words: List[int]) -> bool: + if len(words) != 9: + return False + if words[0] != 1: + return False + if words[2] != 0: + return False + return True + + +__all__ = [ + "TecanInfinite200ProBackend", + "InfiniteScanConfig", + "PyUSBInfiniteTransport", +] diff --git a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py new file mode 100644 index 00000000000..66f63550f0c --- /dev/null +++ b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py @@ -0,0 +1,598 @@ +import unittest + +from pylabrobot.plate_reading.tecan_infinite_backend import ( + InfiniteScanConfig, + TecanInfinite200ProBackend, + _AbsorbanceRunDecoder, + _consume_leading_ascii_frame, + _FluorescenceRunDecoder, + _LuminescenceRunDecoder, +) +from pylabrobot.resources import Coordinate, Plate, Well, create_ordered_items_2d +from pylabrobot.resources.tecan.plates import Plate_384_Well + + +def _pack_words(words): + return b"".join(int(word).to_bytes(2, "big") for word in words) + + +def _make_test_plate(): + plate = Plate( + "plate", + size_x=30, + size_y=20, + size_z=10, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=3, + num_items_y=2, + dx=1, + dy=2, + dz=0, + item_dx=10, + item_dy=8, + size_x=4, + size_y=4, + size_z=5, + ), + ) + plate.location = Coordinate.zero() + return plate + + +def _egg_grid(): + return [ + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 31, + 46, + 42, + 7, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 24, + 69, + 100, + 137, + 142, + 70, + 24, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 24, + 77, + 128, + 135, + 123, + 68, + 52, + 26, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 60, + 104, + 114, + 86, + 72, + 48, + 2, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 75, + 122, + 82, + 71, + 99, + 69, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 64, + 132, + 148, + 61, + 75, + 137, + 86, + 17, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 23, + 98, + 160, + 87, + 92, + 139, + 133, + 65, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 53, + 100, + 93, + 104, + 125, + 146, + 46, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 33, + 73, + 103, + 128, + 143, + 164, + 169, + 61, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 4, + 60, + 93, + 113, + 90, + 107, + 124, + 137, + 118, + 7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 64, + 97, + 98, + 63, + 94, + 95, + 135, + 121, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 36, + 100, + 118, + 119, + 126, + 140, + 154, + 65, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 3, + 40, + 98, + 141, + 150, + 121, + 61, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 75, + 88, + 12, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 45, + 53, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 9, + 11, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ], + ] + + +class TestTecanInfiniteDecoders(unittest.TestCase): + def setUp(self): + self.backend = TecanInfinite200ProBackend() + self.plate = Plate_384_Well(name="plate") + self.grid = _egg_grid() + self.max_intensity = max(max(row) for row in self.grid) + self.scan_wells = self.backend._scan_visit_order(self.plate.get_all_items(), serpentine=True) + + def _assert_matrix(self, actual, expected): + self.assertEqual(len(actual), len(expected)) + for row_actual, row_expected in zip(actual, expected): + self.assertEqual(len(row_actual), len(row_expected)) + for value, exp in zip(row_actual, row_expected): + self.assertAlmostEqual(value or 0.0, exp) + + def _run_decoder_case(self, decoder, build_words, extract_actual): + expected_values = [] + for well in self.scan_wells: + intensity = self.grid[well.get_row()][well.get_column()] + words, expected = build_words(intensity) + decoder.feed(_pack_words(words)) + expected_values.append(expected) + self.assertTrue(decoder.done) + actual_values = extract_actual(decoder) + matrix = self.backend._format_plate_result(self.plate, self.scan_wells, actual_values) + expected = self.backend._format_plate_result(self.plate, self.scan_wells, expected_values) + self._assert_matrix(matrix, expected) + + def test_decode_absorbance_pattern(self): + wavelength = 600 + reference = 10000 + max_absorbance = 1.0 + decoder = _AbsorbanceRunDecoder(len(self.scan_wells), wavelength * 10) + + def build_words(intensity): + target = 0.0 + if self.max_intensity: + target = (intensity / self.max_intensity) * max_absorbance + sample = max(1, int(round(reference / (10**target)))) + words = [1, wavelength * 10, 0, 0, 0, sample, reference, 0, 0] + expected = self.backend._calculate_absorbance_od(sample, reference) + return words, expected + + def extract_actual(decoder): + return [ + self.backend._calculate_absorbance_od(meas.sample, meas.reference) + for meas in decoder.measurements + ] + + self._run_decoder_case(decoder, build_words, extract_actual) + + def test_decode_fluorescence_pattern(self): + excitation = 485 + emission = 520 + decoder = _FluorescenceRunDecoder(len(self.scan_wells), excitation * 10, emission * 10) + + def build_words(intensity): + words = [1, excitation * 10, emission * 10, 0, 0, 0, intensity, 0, 0, 0] + return words, intensity + + def extract_actual(decoder): + return decoder.intensities + + self._run_decoder_case(decoder, build_words, extract_actual) + + def test_decode_luminescence_pattern(self): + decoder = _LuminescenceRunDecoder(len(self.scan_wells)) + + def build_words(intensity): + words = [1, 0, 0, 0, 0, 0, intensity, 0, 0] + return words, intensity + + def extract_actual(decoder): + return [measurement.intensity for measurement in decoder.measurements] + + self._run_decoder_case(decoder, build_words, extract_actual) + + +class TestTecanInfiniteScanGeometry(unittest.TestCase): + def setUp(self): + self.backend = TecanInfinite200ProBackend( + scan_config=InfiniteScanConfig(counts_per_mm_x=1, counts_per_mm_y=1) + ) + self.plate = _make_test_plate() + + def test_scan_visit_order_serpentine(self): + order = self.backend._scan_visit_order(self.plate.get_all_items(), serpentine=True) + identifiers = [well.get_identifier() for well in order] + self.assertEqual(identifiers, ["A1", "A2", "A3", "B3", "B2", "B1"]) + + def test_scan_visit_order_linear(self): + order = self.backend._scan_visit_order(self.plate.get_all_items(), serpentine=False) + identifiers = [well.get_identifier() for well in order] + self.assertEqual(identifiers, ["A1", "A2", "A3", "B1", "B2", "B3"]) + + def test_scan_range_serpentine(self): + setattr(self.backend, "_map_well_to_stage", lambda well: (well.get_column(), well.get_row())) + row_index, row_wells = self.backend._group_by_row(self.plate.get_all_items())[0] + start_x, end_x, count = self.backend._scan_range(row_index, row_wells, serpentine=True) + self.assertEqual((start_x, end_x, count), (0, 2, 3)) + row_index, row_wells = self.backend._group_by_row(self.plate.get_all_items())[1] + start_x, end_x, count = self.backend._scan_range(row_index, row_wells, serpentine=True) + self.assertEqual((start_x, end_x, count), (2, 0, 3)) + + def test_map_well_to_stage(self): + stage_x, stage_y = self.backend._map_well_to_stage(self.plate.get_well("A1")) + self.assertEqual((stage_x, stage_y), (3, 8)) + stage_x, stage_y = self.backend._map_well_to_stage(self.plate.get_well("B1")) + self.assertEqual((stage_x, stage_y), (3, 16)) + + +class TestTecanInfiniteAscii(unittest.TestCase): + def test_frame_ascii_command(self): + framed = TecanInfinite200ProBackend._frame_ascii_command("A") + self.assertEqual(framed, b"\x02A\x03\x00\x00\x01\x40\x0d") + + def test_decode_ascii_frames(self): + data = bytearray(b"\x02HELLO\x03\x00\x00\x05\x00\r\x02ST\x03\x00\x00\x02\x06\r") + frames = TecanInfinite200ProBackend._decode_ascii_frames(data) + self.assertEqual(frames, ["HELLO", "ST"]) + self.assertEqual(data, bytearray()) + + def test_consume_leading_ascii_frame(self): + buffer = bytearray(b"\x02ST\x03\x00\rXYZ") + consumed, text = _consume_leading_ascii_frame(buffer) + self.assertTrue(consumed) + self.assertEqual(text, "ST") + self.assertEqual(buffer, bytearray(b"XYZ")) + + def test_terminal_frames(self): + self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("ST")) + self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("+")) + self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("-")) + self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("BY#T5000")) + self.assertFalse(TecanInfinite200ProBackend._is_terminal_frame("OK")) From f50cebe221433bb2b4b11b79c96f1a338e47b4d5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 20 Dec 2025 12:31:24 -0800 Subject: [PATCH 03/33] Update pylabrobot/plate_reading/tecan_infinite_backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pylabrobot/plate_reading/tecan_infinite_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index a3794b4e126..8467a66fa3b 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -509,7 +509,7 @@ async def read_luminescence( self, plate: Plate, wells: List[Well], - focal_height: float, # noqa: ARG002 + focal_height: float, ) -> List[Dict]: """Queue and execute a luminescence scan.""" From db240386be6117fc554371fcea0a62a03ec1fa55 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 20 Dec 2025 12:31:31 -0800 Subject: [PATCH 04/33] Update pylabrobot/plate_reading/tecan_infinite_backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pylabrobot/plate_reading/tecan_infinite_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 8467a66fa3b..a489b6b4b7f 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -400,9 +400,9 @@ async def read_fluorescence( self, plate: Plate, wells: List[Well], - excitation_wavelength: int, # noqa: ARG002 - emission_wavelength: int, # noqa: ARG002 - focal_height: float, # noqa: ARG002 + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, ) -> List[Dict]: """Queue and execute a fluorescence scan.""" From ca73508f330b52e02f311043ad8025266543837e Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Sun, 21 Dec 2025 19:48:58 +0800 Subject: [PATCH 05/33] Guard ASCII frame parsing for incomplete trailers --- pylabrobot/plate_reading/tecan_infinite_backend.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index a489b6b4b7f..331ce4afaed 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -110,6 +110,8 @@ def _consume_leading_ascii_frame(buffer: bytearray) -> Tuple[bool, Optional[str] end = buffer.find(b"\x03", 1) if end == -1: return False, None + if len(buffer) < end + 6: + return False, None text = buffer[1:end].decode("ascii", "ignore") del buffer[: end + 2] if buffer and buffer[0] == 0x0D: From e92c6dbc1b1f06465329ac22266f9b61e5e1ccc1 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Mon, 22 Dec 2025 20:53:00 +0800 Subject: [PATCH 06/33] Refine Infinite backend decoders based on DLL plus refactoring --- .../plate_reading/tecan_infinite_backend.py | 847 ++++++++++++------ 1 file changed, 575 insertions(+), 272 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 331ce4afaed..17e28046e8e 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -7,12 +7,15 @@ from __future__ import annotations import asyncio +import json import logging import math +import os +import re import time from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Dict, List, Optional, Protocol, Sequence, Tuple +from typing import Dict, List, Optional, Protocol, Sequence, TextIO, Tuple from pylabrobot.io.usb import USB from pylabrobot.plate_reading.backend import PlateReaderBackend @@ -20,6 +23,7 @@ from pylabrobot.resources.well import Well logger = logging.getLogger(__name__) +BIN_RE = re.compile(r"^(\d+),BIN:$") class InfiniteTransport(Protocol): @@ -29,15 +33,19 @@ class InfiniteTransport(Protocol): """ async def open(self) -> None: + """Open the transport connection.""" ... async def close(self) -> None: + """Close the transport connection.""" ... async def write(self, data: bytes) -> None: + """Send raw data to the transport.""" ... async def read(self, size: int) -> bytes: + """Read raw data from the transport.""" ... @@ -87,6 +95,8 @@ async def read(self, size: int) -> bytes: @dataclass class InfiniteScanConfig: + """Scan configuration for Infinite plate readers.""" + flashes: int = 25 counts_per_mm_x: float = 1_000 counts_per_mm_y: float = 1_000 @@ -95,8 +105,258 @@ class InfiniteScanConfig: TecanScanConfig = InfiniteScanConfig -def _be16_words(payload: bytes) -> List[int]: - return [int.from_bytes(payload[i : i + 2], "big") for i in range(0, len(payload), 2)] +def _u16be(payload: bytes, offset: int) -> int: + return int.from_bytes(payload[offset : offset + 2], "big") + + +def _u32be(payload: bytes, offset: int) -> int: + return int.from_bytes(payload[offset : offset + 4], "big") + + +def _i32be(payload: bytes, offset: int) -> int: + return int.from_bytes(payload[offset : offset + 4], "big", signed=True) + + +def _integration_value_to_seconds(value: int) -> float: + return value / 1_000_000.0 if value >= 1000 else value / 1000.0 + + +def _is_abs_prepare_marker(marker: int) -> bool: + return marker >= 22 and (marker - 4) % 18 == 0 + + +def _is_abs_data_marker(marker: int) -> bool: + return marker >= 14 and (marker - 4) % 10 == 0 + + +def _split_payload_and_trailer(marker: int, blob: bytes) -> Optional[Tuple[bytes, Tuple[int, int]]]: + if len(blob) != marker + 4: + return None + payload = blob[:marker] + trailer = blob[marker:] + return payload, (_u16be(trailer, 0), _u16be(trailer, 2)) + + +@dataclass(frozen=True) +class _AbsorbancePrepareItem: + ticker_overflows: int + ticker_counter: int + meas_gain: int + meas_dark: int + meas_bright: int + ref_gain: int + ref_dark: int + ref_bright: int + + +@dataclass(frozen=True) +class _AbsorbancePrepare: + ex: int + items: List[_AbsorbancePrepareItem] + + +def _decode_abs_prepare(marker: int, blob: bytes) -> Optional[_AbsorbancePrepare]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4 + 18: + return None + ex = _u16be(payload, 2) + items_blob = payload[4:] + if len(items_blob) % 18 != 0: + return None + items: List[_AbsorbancePrepareItem] = [] + for off in range(0, len(items_blob), 18): + item = items_blob[off : off + 18] + items.append( + _AbsorbancePrepareItem( + ticker_overflows=_u32be(item, 0), + ticker_counter=_u16be(item, 4), + meas_gain=_u16be(item, 6), + meas_dark=_u16be(item, 8), + meas_bright=_u16be(item, 10), + ref_gain=_u16be(item, 12), + ref_dark=_u16be(item, 14), + ref_bright=_u16be(item, 16), + ) + ) + return _AbsorbancePrepare(ex=ex, items=items) + + +def _decode_abs_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4: + return None + label = _u16be(payload, 0) + ex = _u16be(payload, 2) + off = 4 + items: List[Tuple[int, int]] = [] + while off + 10 <= len(payload): + meas = _u16be(payload, off + 6) + ref = _u16be(payload, off + 8) + items.append((meas, ref)) + off += 10 + if off != len(payload): + return None + return label, ex, items + + +def _absorbance_od_calibrated( + prep: _AbsorbancePrepare, meas_ref_items: List[Tuple[int, int]], od_max: float = 4.0 +) -> float: + if not prep.items: + raise ValueError("ABS prepare packet contained no calibration items.") + + min_corr_trans = math.pow(10.0, -od_max) + + if len(prep.items) == len(meas_ref_items) and len(prep.items) > 1: + corr_trans_vals: List[float] = [] + for (meas, ref), cal in zip(meas_ref_items, prep.items): + denom_corr = cal.meas_bright - cal.meas_dark + if denom_corr == 0: + continue + f_corr = (cal.ref_bright - cal.ref_dark) / denom_corr + denom = ref - cal.ref_dark + if denom == 0: + continue + corr_trans_vals.append(((meas - cal.meas_dark) / denom) * f_corr) + if not corr_trans_vals: + raise ZeroDivisionError("ABS invalid: no usable reads after per-read calibration.") + corr_trans = max(sum(corr_trans_vals) / len(corr_trans_vals), min_corr_trans) + return float(-math.log10(corr_trans)) + + cal0 = prep.items[0] + denom_corr = cal0.meas_bright - cal0.meas_dark + if denom_corr == 0: + raise ZeroDivisionError("ABS calibration invalid: meas_bright == meas_dark") + f_corr = (cal0.ref_bright - cal0.ref_dark) / denom_corr + + trans_vals: List[float] = [] + for meas, ref in meas_ref_items: + denom = ref - cal0.ref_dark + if denom == 0: + continue + trans_vals.append((meas - cal0.meas_dark) / denom) + if not trans_vals: + raise ZeroDivisionError("ABS invalid: all ref reads equal ref_dark") + + trans_mean = sum(trans_vals) / len(trans_vals) + corr_trans = max(trans_mean * f_corr, min_corr_trans) + return float(-math.log10(corr_trans)) + + +@dataclass(frozen=True) +class _FluorescencePrepare: + ex: int + meas_dark: int + ref_dark: int + ref_bright: int + + +def _decode_flr_prepare(marker: int, blob: bytes) -> Optional[_FluorescencePrepare]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) != 18: + return None + return _FluorescencePrepare( + ex=_u16be(payload, 0), + meas_dark=_u16be(payload, 10), + ref_dark=_u16be(payload, 14), + ref_bright=_u16be(payload, 16), + ) + + +def _decode_flr_data( + marker: int, blob: bytes +) -> Optional[Tuple[int, int, int, List[Tuple[int, int]]]]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 6: + return None + label = _u16be(payload, 0) + ex = _u16be(payload, 2) + em = _u16be(payload, 4) + off = 6 + items: List[Tuple[int, int]] = [] + while off + 10 <= len(payload): + meas = _u16be(payload, off + 6) + ref = _u16be(payload, off + 8) + items.append((meas, ref)) + off += 10 + if off != len(payload): + return None + return label, ex, em, items + + +def _fluorescence_corrected( + prep: _FluorescencePrepare, meas_ref_items: List[Tuple[int, int]] +) -> int: + if not meas_ref_items: + return 0 + meas_mean = sum(m for m, _ in meas_ref_items) / len(meas_ref_items) + ref_mean = sum(r for _, r in meas_ref_items) / len(meas_ref_items) + denom = ref_mean - prep.ref_dark + if denom == 0: + return 0 + corr = (meas_mean - prep.meas_dark) * (prep.ref_bright - prep.ref_dark) / denom + return int(round(corr)) + + +@dataclass(frozen=True) +class _LuminescencePrepare: + ref_dark: int + + +def _decode_lum_prepare(marker: int, blob: bytes) -> Optional[_LuminescencePrepare]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) != 10: + return None + return _LuminescencePrepare(ref_dark=_i32be(payload, 6)) + + +def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: + split = _split_payload_and_trailer(marker, blob) + if split is None: + return None + payload, _ = split + if len(payload) < 4: + return None + label = _u16be(payload, 0) + em = _u16be(payload, 2) + off = 4 + counts: List[int] = [] + while off + 10 <= len(payload): + counts.append(_i32be(payload, off + 6)) + off += 10 + if off != len(payload): + return None + return label, em, counts + + +def _luminescence_intensity( + prep: _LuminescencePrepare, + counts: List[int], + dark_integration_s: float, + meas_integration_s: float, +) -> int: + if not counts: + return 0 + if dark_integration_s == 0 or meas_integration_s == 0: + return 0 + count_mean = sum(counts) / len(counts) + corrected_rate = (count_mean / meas_integration_s) - (prep.ref_dark / dark_integration_s) + return int(corrected_rate) StagePosition = Tuple[int, int] @@ -110,10 +370,11 @@ def _consume_leading_ascii_frame(buffer: bytearray) -> Tuple[bool, Optional[str] end = buffer.find(b"\x03", 1) if end == -1: return False, None - if len(buffer) < end + 6: + # Payload is followed by a 4-byte trailer and optional CR. + if len(buffer) < end + 5: return False, None text = buffer[1:end].decode("ascii", "ignore") - del buffer[: end + 2] + del buffer[: end + 5] if buffer and buffer[0] == 0x0D: del buffer[0] return True, text @@ -128,15 +389,82 @@ def _consume_status_frame(buffer: bytearray, length: int) -> bool: return False +@dataclass +class _StreamEvent: + """Parsed stream event (ASCII or binary).""" + + text: Optional[str] = None + marker: Optional[int] = None + blob: Optional[bytes] = None + + +class _StreamParser: + """Parse mixed ASCII and binary packets from the reader.""" + + def __init__( + self, + *, + status_frame_len: Optional[int] = None, + allow_bare_ascii: bool = False, + ) -> None: + """Initialize the stream parser.""" + self._buffer = bytearray() + self._pending_bin: Optional[int] = None + self._status_frame_len = status_frame_len + self._allow_bare_ascii = allow_bare_ascii + + def has_pending_bin(self) -> bool: + """Return True if a binary payload length is pending.""" + return self._pending_bin is not None + + def feed(self, chunk: bytes) -> List[_StreamEvent]: + """Feed raw bytes and return newly parsed events.""" + self._buffer.extend(chunk) + events: List[_StreamEvent] = [] + progressed = True + while progressed: + progressed = False + if self._pending_bin is not None: + need = self._pending_bin + 4 + if len(self._buffer) < need: + break + blob = bytes(self._buffer[:need]) + del self._buffer[:need] + events.append(_StreamEvent(marker=self._pending_bin, blob=blob)) + self._pending_bin = None + progressed = True + continue + if self._status_frame_len and _consume_status_frame(self._buffer, self._status_frame_len): + progressed = True + continue + consumed, text = _consume_leading_ascii_frame(self._buffer) + if consumed: + events.append(_StreamEvent(text=text)) + if text: + m = BIN_RE.match(text) + if m: + self._pending_bin = int(m.group(1)) + progressed = True + continue + if self._allow_bare_ascii and self._buffer and all(32 <= b <= 126 for b in self._buffer): + text = self._buffer.decode("ascii", "ignore") + self._buffer.clear() + events.append(_StreamEvent(text=text)) + progressed = True + continue + return events + + class _MeasurementDecoder(ABC): """Shared incremental decoder for Infinite measurement streams.""" STATUS_FRAME_LEN: Optional[int] = None def __init__(self, expected: int) -> None: + """Initialize decoder state for a scan with expected measurements.""" self.expected = expected - self._buffer: bytearray = bytearray() self._terminal_seen = False + self._parser = _StreamParser(status_frame_len=self.STATUS_FRAME_LEN) @property @abstractmethod @@ -145,44 +473,35 @@ def count(self) -> int: @property def done(self) -> bool: + """Return True if the decoder has seen all expected measurements.""" return self.count >= self.expected def pop_terminal(self) -> bool: + """Return and clear the terminal frame seen flag.""" seen = self._terminal_seen self._terminal_seen = False return seen def feed(self, chunk: bytes) -> None: - self._buffer.extend(chunk) - progressed = True - while progressed: - progressed = False - consumed, text = _consume_leading_ascii_frame(self._buffer) - if consumed: - if text == "ST": + """Consume a raw chunk and update decoder state.""" + for event in self._parser.feed(chunk): + if event.text is not None: + if event.text == "ST": self._terminal_seen = True - progressed = True - continue - if not self.done and self._consume_measurement(): - progressed = True - continue - if self.STATUS_FRAME_LEN and _consume_status_frame(self._buffer, self.STATUS_FRAME_LEN): - progressed = True - continue - if self.done or not self._buffer: - break - progressed = self._discard_byte() + elif event.marker is not None and event.blob is not None: + self.feed_bin(event.marker, event.blob) - @abstractmethod - def _consume_measurement(self) -> bool: - """Attempt to consume a measurement frame from the buffer.""" + def feed_bin(self, marker: int, blob: bytes) -> None: + """Handle a binary payload if the decoder expects one.""" + if self._should_consume_bin(marker): + self._handle_bin(marker, blob) - def _discard_byte(self) -> bool: - if self._buffer: - del self._buffer[0] - return True + def _should_consume_bin(self, _marker: int) -> bool: return False + def _handle_bin(self, _marker: int, _blob: bytes) -> None: + return None + class TecanInfinite200ProBackend(PlateReaderBackend): """Backend shell for the Infinite 200 PRO.""" @@ -206,7 +525,7 @@ class TecanInfinite200ProBackend(PlateReaderBackend): # "#TEMPERATURE PLATE", ], "FI.TOP": [ - "#BEAM DIAMETER", + # "#BEAM DIAMETER", # Additional capabilities available but currently unused: # "#EMISSION WAVELENGTH", # "#EMISSION USAGE", @@ -232,7 +551,7 @@ class TecanInfinite200ProBackend(PlateReaderBackend): # "#TEMPERATURE PLATE", ], "FI.BOTTOM": [ - "#BEAM DIAMETER", + # "#BEAM DIAMETER", # Additional capabilities available but currently unused: # "#EMISSION WAVELENGTH", # "#EMISSION USAGE", @@ -243,7 +562,7 @@ class TecanInfinite200ProBackend(PlateReaderBackend): # "#TIME READDELAY", ], "LUM": [ - "#BEAM DIAMETER", + # "#BEAM DIAMETER", # Additional capabilities available but currently unused: # "#EMISSION WAVELENGTH", # "#EMISSION USAGE", @@ -260,7 +579,9 @@ def __init__( self, transport: Optional[InfiniteTransport] = None, scan_config: Optional[InfiniteScanConfig] = None, + packet_log_path: Optional[str] = None, ) -> None: + super().__init__() self._transport = transport or PyUSBInfiniteTransport() self.config = scan_config or InfiniteScanConfig() self._setup_lock = asyncio.Lock() @@ -271,6 +592,12 @@ def __init__( self._mode_capabilities: Dict[str, Dict[str, str]] = {} self._current_fluorescence_excitation: Optional[int] = None self._current_fluorescence_emission: Optional[int] = None + self._lum_integration_s: Dict[int, float] = {} + self._pending_bin_events: List[Tuple[int, bytes]] = [] + self._ascii_parser = _StreamParser(allow_bare_ascii=True) + self._packet_log_path = packet_log_path + self._packet_log_handle: Optional[TextIO] = None + self._packet_log_lock = asyncio.Lock() async def setup(self) -> None: async with self._setup_lock: @@ -288,6 +615,9 @@ async def stop(self) -> None: if not self._ready: return await self._transport.close() + if self._packet_log_handle is not None: + self._packet_log_handle.close() + self._packet_log_handle = None self._ready = False async def open(self) -> None: @@ -310,11 +640,10 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - + step_loss = ["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"] await self._begin_run() try: - wl_decitenth = int(round(wavelength * 10)) - decoder = _AbsorbanceRunDecoder(len(scan_wells), wl_decitenth) + decoder = _AbsorbanceRunDecoder(len(scan_wells)) await self._configure_absorbance(wavelength) for row_index, row_wells in self._group_by_row(ordered_wells): @@ -323,7 +652,9 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") await self._send_ascii("SCAN DIRECTION=ALTUP") - await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + await self._send_ascii( + f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False + ) logger.info( "Queued scan row %s (%s wells): y=%s, x=%s..%s", row_index, @@ -337,9 +668,14 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int if len(decoder.measurements) != len(scan_wells): raise RuntimeError("Absorbance decoder did not complete scan.") - intensities = [ - self._calculate_absorbance_od(meas.sample, meas.reference) for meas in decoder.measurements - ] + intensities: List[float] = [] + prep = decoder.prepare + if prep is None: + raise RuntimeError("ABS prepare packet not seen; cannot compute calibrated OD.") + for meas in decoder.measurements: + items = meas.items or [(meas.sample, meas.reference)] + od = _absorbance_od_calibrated(prep, items) + intensities.append(od) matrix = self._format_plate_result(plate, scan_wells, intensities) return [ { @@ -350,12 +686,12 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int } ] finally: - await self._end_run(["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"]) + await self._end_run(step_loss) async def _configure_absorbance(self, wavelength_nm: int) -> None: wl_decitenth = int(round(wavelength_nm * 10)) bw_decitenth = int(round(self._auto_bandwidth(wavelength_nm) * 10)) - reads_number = 1 + reads_number = max(1, int(self.config.flashes)) await self._send_ascii("MODE ABS") @@ -380,24 +716,16 @@ async def _configure_absorbance(self, wavelength_nm: int) -> None: ] for cmd in commands: - await self._send_ascii(cmd) + if cmd == "PREPARE REF": + await self._send_ascii(cmd, allow_timeout=True, read_response=False) + else: + await self._send_ascii(cmd, allow_timeout=True) def _auto_bandwidth(self, wavelength_nm: int) -> float: """Return bandwidth in nm based on Infinite M specification.""" return 9.0 if wavelength_nm > 315 else 5.0 - @staticmethod - def _calculate_absorbance_od( - sample: int, - reference: int, - ) -> float: - """Return log10(reference / sample) with guard rails around zero.""" - - safe_sample = max(sample, 1) - safe_reference = max(reference, 1) - return float(math.log10(safe_reference / safe_sample)) - async def read_fluorescence( self, plate: Plate, @@ -417,6 +745,7 @@ async def read_fluorescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + step_loss = ["CHECK MTP.STEPLOSS", "CHECK FI.TOP.STEPLOSS", "CHECK FI.STEPLOSS.Z"] await self._begin_run() try: await self._configure_fluorescence(excitation_wavelength, emission_wavelength) @@ -434,7 +763,9 @@ async def read_fluorescence( await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") await self._send_ascii("SCAN DIRECTION=UP") - await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + await self._send_ascii( + f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False + ) logger.info( "Queued fluorescence scan row %s (%s wells): y=%s, x=%s..%s", row_index, @@ -460,14 +791,14 @@ async def read_fluorescence( } ] finally: - await self._end_run(["CHECK MTP.STEPLOSS", "CHECK FI.TOP.STEPLOSS", "CHECK FI.STEPLOSS.Z"]) + await self._end_run(step_loss) async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> None: ex_decitenth = int(round(excitation_nm * 10)) em_decitenth = int(round(emission_nm * 10)) self._current_fluorescence_excitation = ex_decitenth self._current_fluorescence_emission = em_decitenth - reads_number = 1 + reads_number = max(1, int(self.config.flashes)) clear_cmds = [ "MODE FI.TOP", "READS CLEAR", @@ -502,10 +833,10 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> # UI issues the entire FI configuration twice before PREPARE REF. for _ in range(2): for cmd in clear_cmds: - await self._send_ascii(cmd) + await self._send_ascii(cmd, allow_timeout=True) for cmd in configure_cmds: - await self._send_ascii(cmd) - await self._send_ascii("PREPARE REF") + await self._send_ascii(cmd, allow_timeout=True) + await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) async def read_luminescence( self, @@ -515,16 +846,22 @@ async def read_luminescence( ) -> List[Dict]: """Queue and execute a luminescence scan.""" - logger.warning("Luminescence path is experimental; decoding is not yet validated.") if focal_height < 0: raise ValueError("Focal height must be non-negative for luminescence scans.") ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) + step_loss = ["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"] await self._begin_run() try: await self._configure_luminescence() - decoder = _LuminescenceRunDecoder(len(scan_wells)) + dark_t = self._lum_integration_s.get(0, 0.0) + meas_t = self._lum_integration_s.get(1, 0.0) + decoder = _LuminescenceRunDecoder( + len(scan_wells), + dark_integration_s=dark_t, + meas_integration_s=meas_t, + ) for row_index, row_wells in self._group_by_row(ordered_wells): start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=False) @@ -532,7 +869,9 @@ async def read_luminescence( await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") await self._send_ascii("SCAN DIRECTION=UP") - await self._send_ascii(f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False) + await self._send_ascii( + f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False + ) logger.info( "Queued luminescence scan row %s (%s wells): y=%s, x=%s..%s", row_index, @@ -556,15 +895,19 @@ async def read_luminescence( } ] finally: - await self._end_run(["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"]) + await self._end_run(step_loss) async def _await_measurements( self, decoder: "_MeasurementDecoder", row_count: int, mode: str ) -> None: target = decoder.count + row_count + if self._pending_bin_events: + for marker, blob in self._pending_bin_events: + decoder.feed_bin(marker, blob) + self._pending_bin_events.clear() iterations = 0 while decoder.count < target and iterations < self._max_read_iterations: - chunk = await self._transport.read(self._read_chunk_size) + chunk = await self._read_packet(self._read_chunk_size) if not chunk: raise RuntimeError(f"{mode} read returned empty chunk; transport may not support reads.") decoder.feed(chunk) @@ -584,6 +927,11 @@ async def _configure_luminescence(self) -> None: await self._send_ascii("CHECK LUM.LID") await self._send_ascii("CHECK LUM.STEPLOSS") await self._send_ascii("MODE LUM") + reads_number = max(1, int(self.config.flashes)) + self._lum_integration_s = { + 0: _integration_value_to_seconds(3_000_000), + 1: _integration_value_to_seconds(1_000_000), + } commands = [ "READS CLEAR", "EMISSION CLEAR", @@ -593,16 +941,21 @@ async def _configure_luminescence(self) -> None: "MIRROR CLEAR", "POSITION LUM,Z=14620", "TIME 0,INTEGRATION=3000000", + f"READS 0,NUMBER={reads_number}", "SCAN DIRECTION=UP", "RATIO LABELS=1", "EMISSION 1,EMPTY,0,0,0", "TIME 1,INTEGRATION=1000000", "TIME 1,READDELAY=0", + f"READS 1,NUMBER={reads_number}", "#EMISSION ATTENUATION", "PREPARE REF", ] for cmd in commands: - await self._send_ascii(cmd) + if cmd == "PREPARE REF": + await self._send_ascii(cmd, allow_timeout=True, read_response=False) + else: + await self._send_ascii(cmd, allow_timeout=True) def _group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: grouped: Dict[int, List[Well]] = {} @@ -672,8 +1025,41 @@ async def _initialize_device(self) -> None: async def _begin_run(self) -> None: await self._initialize_device() + self._reset_stream_state() await self._send_ascii("KEYLOCK ON") + def _reset_stream_state(self) -> None: + self._pending_bin_events.clear() + self._ascii_parser = _StreamParser(allow_bare_ascii=True) + + async def _read_packet(self, size: int) -> bytes: + data = await self._transport.read(size) + if data: + await self._log_packet("in", data) + return data + + async def _log_packet( + self, direction: str, data: bytes, ascii_payload: Optional[str] = None + ) -> None: + if not self._packet_log_path: + return + async with self._packet_log_lock: + if self._packet_log_handle is None: + parent = os.path.dirname(self._packet_log_path) + if parent: + os.makedirs(parent, exist_ok=True) + self._packet_log_handle = open(self._packet_log_path, "a", encoding="utf-8") + record = { + "ts": time.time(), + "dir": direction, + "size": len(data), + "data_hex": data.hex(), + } + if ascii_payload is not None: + record["ascii"] = ascii_payload + self._packet_log_handle.write(json.dumps(record) + "\n") + self._packet_log_handle.flush() + async def _end_run(self, step_loss_commands: Sequence[str]) -> None: await self._send_ascii("TERMINATE") for cmd in step_loss_commands: @@ -729,56 +1115,66 @@ def _frame_ascii_command(command: str) -> bytes: length = len(payload) & 0xFF return b"\x02" + payload + b"\x03\x00\x00" + bytes([length, checksum]) + b"\x0d" - async def _send_ascii(self, command: str, wait_for_terminal: bool = True) -> List[str]: + async def _send_ascii( + self, + command: str, + wait_for_terminal: bool = True, + allow_timeout: bool = False, + read_response: bool = True, + ) -> List[str]: logger.debug("[tecan] >> %s", command) framed = self._frame_ascii_command(command) await self._transport.write(framed) + await self._log_packet("out", framed, ascii_payload=command) + if not read_response: + return [] if command.startswith(("#", "?")): - frames = await self._read_ascii_response(require_terminal=False) - return frames - frames = await self._read_ascii_response(require_terminal=wait_for_terminal) + try: + return await self._read_ascii_response(require_terminal=False) + except TimeoutError: + if allow_timeout: + logger.warning("Timeout waiting for response to %s", command) + return [] + raise + try: + frames = await self._read_ascii_response(require_terminal=wait_for_terminal) + except TimeoutError: + if allow_timeout: + logger.warning("Timeout waiting for response to %s", command) + return [] + raise for pkt in frames: logger.debug("[tecan] << %s", pkt) return frames async def _drain_ascii(self, attempts: int = 4) -> None: + """Read and discard a few ASCII packets to clear the stream.""" for _ in range(attempts): - data = await self._transport.read(128) + data = await self._read_packet(128) if not data: break async def _read_ascii_response( self, max_iterations: int = 8, require_terminal: bool = True ) -> List[str]: - buffer = bytearray() + """Read ASCII frames and cache any binary payloads that arrive.""" frames: List[str] = [] saw_terminal = False for _ in range(max_iterations): - chunk = await self._transport.read(128) + chunk = await self._read_packet(128) if not chunk: break - buffer.extend(chunk) - decoded = self._decode_ascii_frames(buffer) - if decoded: - frames.extend(decoded) - if not require_terminal: - break - if any(self._is_terminal_frame(text) for text in decoded): - saw_terminal = True - break - continue - if buffer and all(32 <= b <= 126 for b in buffer): - text = "" - try: - text = buffer.decode("ascii", "ignore") - frames.append(text) - except Exception: - pass - buffer.clear() - if self._is_terminal_frame(text): - saw_terminal = True - break - continue + for event in self._ascii_parser.feed(chunk): + if event.text is not None: + frames.append(event.text) + if self._is_terminal_frame(event.text): + saw_terminal = True + elif event.marker is not None and event.blob is not None: + self._pending_bin_events.append((event.marker, event.blob)) + if not require_terminal and frames and not self._ascii_parser.has_pending_bin(): + break + if require_terminal and saw_terminal and not self._ascii_parser.has_pending_bin(): + break if require_terminal and not saw_terminal: # best effort: drain once more so pending ST doesn't leak into next command await self._drain_ascii(1) @@ -786,116 +1182,73 @@ async def _read_ascii_response( @staticmethod def _is_terminal_frame(text: str) -> bool: + """Return True if the ASCII frame is a terminal marker.""" return text in {"ST", "+", "-"} or text.startswith("BY#T") - @staticmethod - def _decode_ascii_frames(data: bytearray) -> List[str]: - frames: List[str] = [] - while True: - try: - stx = data.index(0x02) - except ValueError: - data.clear() - break - if stx > 0: - del data[:stx] - try: - etx = data.index(0x03, 1) - except ValueError: - break - trailer_len = 4 if len(data) >= etx + 5 else 0 - frame_end = etx + 1 + trailer_len - if len(data) < frame_end: - break - payload = data[1:etx] - try: - frames.append(payload.decode("ascii", "ignore")) - except Exception: - frames.append(payload.hex()) - del data[:frame_end] - if data and data[0] == 0x0D: - del data[0] - return frames - - @dataclass class _AbsorbanceMeasurement: sample: int reference: int + items: Optional[List[Tuple[int, int]]] = None class _AbsorbanceRunDecoder(_MeasurementDecoder): """Incrementally decode absorbance measurement frames.""" STATUS_FRAME_LEN = 31 - _MEAS_LEN = 18 - def __init__(self, expected: int, wavelength_decitenth: int, skip_initial: int = 0) -> None: + def __init__(self, expected: int) -> None: super().__init__(expected) - self._wavelength = wavelength_decitenth self.measurements: List[_AbsorbanceMeasurement] = [] - self._skip_initial = max(0, skip_initial) + self._prepare: Optional[_AbsorbancePrepare] = None @property def count(self) -> int: return len(self.measurements) - def _consume_measurement(self) -> bool: - frame = self._find_measurement_frame() - if frame is None: - return False - offset, length = frame - if offset: - del self._buffer[:offset] - payload = bytes(self._buffer[:length]) - del self._buffer[:length] - self._handle_measurement(payload) - return True + @property + def prepare(self) -> Optional[_AbsorbancePrepare]: + """Return the absorbance prepare data, if available.""" + return self._prepare - def _handle_measurement(self, payload: bytes) -> None: - words = _be16_words(payload) - if len(words) != 9: - return - if not self._words_match_measurement(words): - return - meas = _AbsorbanceMeasurement(sample=words[5], reference=words[6]) - if self._skip_initial > 0: - self._skip_initial -= 1 - return - self.measurements.append(meas) - - def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: - limit = len(self._buffer) - self._MEAS_LEN - for offset in range(0, limit + 1, 2): - chunk = self._buffer[offset : offset + self._MEAS_LEN] - words = _be16_words(chunk) - if len(words) == 9 and self._words_match_measurement(words): - return offset, self._MEAS_LEN - return None + def _should_consume_bin(self, marker: int) -> bool: + return _is_abs_prepare_marker(marker) or _is_abs_data_marker(marker) - def _words_match_measurement(self, words: List[int]) -> bool: - if len(words) != 9: - return False - if words[0] != 1 or words[2] != 0: - return False - if abs(words[1] - self._wavelength) > 1: - return False - return True + def _handle_bin(self, marker: int, blob: bytes) -> None: + if _is_abs_prepare_marker(marker): + if self._prepare is not None: + return + decoded = _decode_abs_prepare(marker, blob) + if decoded is not None: + self._prepare = decoded + return + if _is_abs_data_marker(marker): + decoded = _decode_abs_data(marker, blob) + if decoded is None: + return + _label, _ex, items = decoded + sample, reference = items[0] if items else (0, 0) + self.measurements.append( + _AbsorbanceMeasurement(sample=sample, reference=reference, items=items) + ) class _FluorescenceRunDecoder(_MeasurementDecoder): - """Incrementally decode fluorescence measurement frames from measurement tails.""" + """Incrementally decode fluorescence measurement frames.""" STATUS_FRAME_LEN = 31 - _MEAS_LEN = 20 def __init__( - self, expected_wells: int, excitation_decitenth: int, emission_decitenth: Optional[int] + self, + expected_wells: int, + excitation_decitenth: int, + emission_decitenth: Optional[int], ) -> None: super().__init__(expected_wells) self._excitation = excitation_decitenth self._emission = emission_decitenth self._intensities: List[int] = [] + self._prepare: Optional[_FluorescencePrepare] = None @property def count(self) -> int: @@ -903,138 +1256,88 @@ def count(self) -> int: @property def intensities(self) -> List[int]: + """Return decoded fluorescence intensities.""" return self._intensities - def _consume_measurement(self) -> bool: - frame = self._find_measurement_frame() - if frame: - offset, length = frame - if offset: - del self._buffer[:offset] - tail = bytes(self._buffer[:length]) - del self._buffer[:length] - self._handle_measurement_tail(tail) + def _should_consume_bin(self, marker: int) -> bool: + if marker == 18: return True - calib_len = self._calibration_frame_len() - if calib_len: - del self._buffer[:calib_len] + if marker >= 16 and (marker - 6) % 10 == 0: return True return False - def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: - limit = len(self._buffer) - self._MEAS_LEN - for offset in range(0, limit + 1, 2): - chunk = self._buffer[offset : offset + self._MEAS_LEN] - words = _be16_words(chunk) - if len(words) == 10 and self._words_match_measurement(words): - return offset, self._MEAS_LEN - return None - - def _calibration_frame_len(self) -> Optional[int]: - return None - - def _handle_measurement_tail(self, tail: bytes) -> None: - words = _be16_words(tail) - intensity = None - if len(words) == 10 and self._words_match_measurement(words): - intensity = words[6] - if intensity is not None: - self._intensities.append(intensity) - - def _words_match_measurement(self, words: List[int]) -> bool: - if not words: - return False - excit = words[1] - emiss = words[2] - if words[0] != 1: - return False - if abs(excit - self._excitation) > 1: - return False - if self._emission is not None and abs(emiss - self._emission) > 1: - return False - return True - - def _discard_byte(self) -> bool: - if self._buffer: - del self._buffer[0] - return True - return False + def _handle_bin(self, marker: int, blob: bytes) -> None: + if marker == 18: + decoded = _decode_flr_prepare(marker, blob) + if decoded is not None: + self._prepare = decoded + return + decoded = _decode_flr_data(marker, blob) + if decoded is None: + return + _label, _ex, _em, items = decoded + if self._prepare is not None: + intensity = _fluorescence_corrected(self._prepare, items) + else: + if not items: + intensity = 0 + else: + intensity = int(round(sum(m for m, _ in items) / len(items))) + self._intensities.append(intensity) @dataclass class _LuminescenceMeasurement: - raw_tail: int intensity: int - words: List[int] class _LuminescenceRunDecoder(_MeasurementDecoder): """Incrementally decode luminescence measurement frames.""" - FRAME_LEN = 45 - _MEAS_LEN = 18 - - def __init__(self, expected: int) -> None: + def __init__( + self, + expected: int, + *, + dark_integration_s: float = 0.0, + meas_integration_s: float = 0.0, + ) -> None: super().__init__(expected) self.measurements: List[_LuminescenceMeasurement] = [] + self._prepare: Optional[_LuminescencePrepare] = None + self._dark_integration_s = float(dark_integration_s) + self._meas_integration_s = float(meas_integration_s) @property def count(self) -> int: return len(self.measurements) - def _consume_measurement(self) -> bool: - frame = self._find_measurement_frame() - if frame: - offset, length = frame - if offset: - del self._buffer[:offset] - payload = bytes(self._buffer[:length]) - del self._buffer[:length] - self._handle_measurement(payload) + def _should_consume_bin(self, marker: int) -> bool: + if marker == 10: return True - return False - - def _discard_byte(self) -> bool: - if self._buffer and self._buffer[0] not in (0x02, 0x1B): - del self._buffer[0] + if marker >= 14 and (marker - 4) % 10 == 0: return True return False - def _find_measurement_frame(self) -> Optional[Tuple[int, int]]: - limit = len(self._buffer) - self._MEAS_LEN - for offset in range(0, limit + 1, 2): - chunk = self._buffer[offset : offset + self._MEAS_LEN] - words = _be16_words(chunk) - if len(words) == 9 and self._words_match_measurement(words): - return offset, self._MEAS_LEN - return None - - def _handle_measurement(self, payload: bytes) -> None: - words = _be16_words(payload) - if len(words) != 9: + def _handle_bin(self, marker: int, blob: bytes) -> None: + if marker == 10: + decoded = _decode_lum_prepare(marker, blob) + if decoded is not None: + self._prepare = decoded return - if not self._words_match_measurement(words): + decoded = _decode_lum_data(marker, blob) + if decoded is None: return - raw_tail = payload[-1] if payload else 0 - # Unconfirmed: using words[6] as luminescence intensity; not validated against OEM exports due to lack of proper glowing sample. - intensity = words[6] - self.measurements.append( - _LuminescenceMeasurement( - raw_tail=raw_tail, - intensity=intensity, - words=words, + _label, _em, counts = decoded + if self._prepare is not None and self._dark_integration_s and self._meas_integration_s: + intensity = _luminescence_intensity( + self._prepare, counts, self._dark_integration_s, self._meas_integration_s ) + else: + intensity = int(round(sum(counts) / len(counts))) if counts else 0 + self.measurements.append( + _LuminescenceMeasurement(intensity=intensity) ) - def _words_match_measurement(self, words: List[int]) -> bool: - if len(words) != 9: - return False - if words[0] != 1: - return False - if words[2] != 0: - return False - return True - __all__ = [ "TecanInfinite200ProBackend", From 59ef4e87a9261d6781a7f632591128bb79a56bb1 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Mon, 22 Dec 2025 20:53:34 +0800 Subject: [PATCH 07/33] Add Infinite USB lifecycle cleanup and recovery --- .../plate_reading/tecan_infinite_backend.py | 73 +++++++++++++++++-- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 17e28046e8e..e66fe397adf 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -48,6 +48,10 @@ async def read(self, size: int) -> bytes: """Read raw data from the transport.""" ... + async def reset(self) -> None: + """Reset the transport connection.""" + ... + class PyUSBInfiniteTransport(InfiniteTransport): """Transport that reuses pylabrobot.io.usb.USB for Infinite communication.""" @@ -80,6 +84,11 @@ async def close(self) -> None: await self._usb.stop() self._usb = None + async def reset(self) -> None: + await self.close() + await asyncio.sleep(0.2) + await self.open() + async def write(self, data: bytes) -> None: if self._usb is None or self._usb.write_endpoint is None: raise RuntimeError("USB transport not opened.") @@ -595,6 +604,9 @@ def __init__( self._lum_integration_s: Dict[int, float] = {} self._pending_bin_events: List[Tuple[int, bytes]] = [] self._ascii_parser = _StreamParser(allow_bare_ascii=True) + self._run_active = False + self._active_step_loss_commands: List[str] = [] + self._active_mode: Optional[str] = None self._packet_log_path = packet_log_path self._packet_log_handle: Optional[TextIO] = None self._packet_log_lock = asyncio.Lock() @@ -614,10 +626,14 @@ async def stop(self) -> None: async with self._setup_lock: if not self._ready: return + await self._cleanup_protocol() await self._transport.close() if self._packet_log_handle is not None: self._packet_log_handle.close() self._packet_log_handle = None + self._device_initialized = False + self._mode_capabilities.clear() + self._reset_stream_state() self._ready = False async def open(self) -> None: @@ -640,7 +656,10 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + step_loss = ["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"] + self._active_step_loss_commands = list(step_loss) + self._active_mode = "ABS" await self._begin_run() try: decoder = _AbsorbanceRunDecoder(len(scan_wells)) @@ -746,6 +765,8 @@ async def read_fluorescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) step_loss = ["CHECK MTP.STEPLOSS", "CHECK FI.TOP.STEPLOSS", "CHECK FI.STEPLOSS.Z"] + self._active_step_loss_commands = list(step_loss) + self._active_mode = "FI.TOP" await self._begin_run() try: await self._configure_fluorescence(excitation_wavelength, emission_wavelength) @@ -852,6 +873,8 @@ async def read_luminescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) step_loss = ["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"] + self._active_step_loss_commands = list(step_loss) + self._active_mode = "LUM" await self._begin_run() try: await self._configure_luminescence() @@ -1027,17 +1050,36 @@ async def _begin_run(self) -> None: await self._initialize_device() self._reset_stream_state() await self._send_ascii("KEYLOCK ON") + self._run_active = True def _reset_stream_state(self) -> None: self._pending_bin_events.clear() self._ascii_parser = _StreamParser(allow_bare_ascii=True) async def _read_packet(self, size: int) -> bytes: - data = await self._transport.read(size) + try: + data = await self._transport.read(size) + except TimeoutError: + await self._recover_transport() + raise if data: await self._log_packet("in", data) return data + async def _recover_transport(self) -> None: + try: + await self._transport.reset() + except Exception: + try: + await self._transport.close() + await asyncio.sleep(0.2) + await self._transport.open() + except Exception: + return + self._device_initialized = False + self._mode_capabilities.clear() + self._reset_stream_state() + async def _log_packet( self, direction: str, data: bytes, ascii_payload: Optional[str] = None ) -> None: @@ -1061,11 +1103,30 @@ async def _log_packet( self._packet_log_handle.flush() async def _end_run(self, step_loss_commands: Sequence[str]) -> None: - await self._send_ascii("TERMINATE") - for cmd in step_loss_commands: - await self._send_ascii(cmd) - await self._send_ascii("KEYLOCK OFF") - await self._send_ascii("ABSOLUTE MTP,IN") + try: + await self._send_ascii("TERMINATE", allow_timeout=True) + for cmd in step_loss_commands: + await self._send_ascii(cmd, allow_timeout=True) + await self._send_ascii("KEYLOCK OFF", allow_timeout=True) + await self._send_ascii("ABSOLUTE MTP,IN", allow_timeout=True) + finally: + self._run_active = False + self._active_step_loss_commands = [] + self._active_mode = None + + async def _cleanup_protocol(self) -> None: + if not self._run_active and not self._active_step_loss_commands: + commands = ["KEYLOCK OFF", "ABSOLUTE MTP,IN"] + else: + commands = ["TERMINATE", *self._active_step_loss_commands, "KEYLOCK OFF", "ABSOLUTE MTP,IN"] + for cmd in commands: + try: + await self._send_ascii(cmd, allow_timeout=True, read_response=False) + except Exception: + logger.warning("Cleanup command failed: %s", cmd) + self._run_active = False + self._active_step_loss_commands = [] + self._active_mode = None async def _query_mode_capabilities(self, mode: str) -> None: commands = self._MODE_CAPABILITY_COMMANDS.get(mode) From da96aa27bb9d835ae1c266fd6ead570fe533c3f1 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Tue, 23 Dec 2025 11:10:30 +0800 Subject: [PATCH 08/33] Update Infinite backend decoder tests --- .../tecan_infinite_backend_tests.py | 99 ++++++++++++++----- 1 file changed, 73 insertions(+), 26 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py index 66f63550f0c..cec8c52ab15 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py @@ -4,6 +4,7 @@ InfiniteScanConfig, TecanInfinite200ProBackend, _AbsorbanceRunDecoder, + _absorbance_od_calibrated, _consume_leading_ascii_frame, _FluorescenceRunDecoder, _LuminescenceRunDecoder, @@ -12,10 +13,48 @@ from pylabrobot.resources.tecan.plates import Plate_384_Well -def _pack_words(words): +def _pack_u16(words): return b"".join(int(word).to_bytes(2, "big") for word in words) +def _bin_blob(payload): + marker = len(payload) + trailer = b"\x00\x00\x00\x00" + return marker, payload + trailer + + +def _abs_prepare_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): + header = _pack_u16([0, ex_decitenth]) + item = ( + (0).to_bytes(4, "big") + + _pack_u16([0, 0, meas_dark, meas_bright, 0, ref_dark, ref_bright]) + ) + return _bin_blob(header + item) + + +def _abs_data_blob(ex_decitenth, meas, ref): + payload = _pack_u16([0, ex_decitenth, 0, 0, 0, meas, ref]) + return _bin_blob(payload) + + +def _flr_prepare_blob(ex_decitenth, meas_dark, ref_dark, ref_bright): + words = [ex_decitenth, 0, 0, 0, 0, meas_dark, 0, ref_dark, ref_bright] + return _bin_blob(_pack_u16(words)) + + +def _flr_data_blob(ex_decitenth, em_decitenth, meas, ref): + words = [0, ex_decitenth, em_decitenth, 0, 0, 0, meas, ref] + return _bin_blob(_pack_u16(words)) + + +def _lum_data_blob(em_decitenth, intensity): + payload = bytearray(14) + payload[0:2] = (0).to_bytes(2, "big") + payload[2:4] = int(em_decitenth).to_bytes(2, "big") + payload[10:14] = int(intensity).to_bytes(4, "big", signed=True) + return _bin_blob(bytes(payload)) + + def _make_test_plate(): plate = Plate( "plate", @@ -476,12 +515,12 @@ def _assert_matrix(self, actual, expected): for value, exp in zip(row_actual, row_expected): self.assertAlmostEqual(value or 0.0, exp) - def _run_decoder_case(self, decoder, build_words, extract_actual): + def _run_decoder_case(self, decoder, build_packet, extract_actual): expected_values = [] for well in self.scan_wells: intensity = self.grid[well.get_row()][well.get_column()] - words, expected = build_words(intensity) - decoder.feed(_pack_words(words)) + marker, blob, expected = build_packet(intensity) + decoder.feed_bin(marker, blob) expected_values.append(expected) self.assertTrue(decoder.done) actual_values = extract_actual(decoder) @@ -493,50 +532,64 @@ def test_decode_absorbance_pattern(self): wavelength = 600 reference = 10000 max_absorbance = 1.0 - decoder = _AbsorbanceRunDecoder(len(self.scan_wells), wavelength * 10) + decoder = _AbsorbanceRunDecoder(len(self.scan_wells)) + prep_marker, prep_blob = _abs_prepare_blob( + wavelength * 10, + meas_dark=0, + meas_bright=1000, + ref_dark=0, + ref_bright=1000, + ) + decoder.feed_bin(prep_marker, prep_blob) + prep = decoder.prepare + self.assertIsNotNone(prep) - def build_words(intensity): + def build_packet(intensity): target = 0.0 if self.max_intensity: target = (intensity / self.max_intensity) * max_absorbance sample = max(1, int(round(reference / (10**target)))) - words = [1, wavelength * 10, 0, 0, 0, sample, reference, 0, 0] - expected = self.backend._calculate_absorbance_od(sample, reference) - return words, expected + marker, blob = _abs_data_blob(wavelength * 10, sample, reference) + expected = _absorbance_od_calibrated(prep, [(sample, reference)]) + return marker, blob, expected def extract_actual(decoder): return [ - self.backend._calculate_absorbance_od(meas.sample, meas.reference) + _absorbance_od_calibrated(prep, [(meas.sample, meas.reference)]) for meas in decoder.measurements ] - self._run_decoder_case(decoder, build_words, extract_actual) + self._run_decoder_case(decoder, build_packet, extract_actual) def test_decode_fluorescence_pattern(self): excitation = 485 emission = 520 decoder = _FluorescenceRunDecoder(len(self.scan_wells), excitation * 10, emission * 10) + prep_marker, prep_blob = _flr_prepare_blob( + excitation * 10, meas_dark=0, ref_dark=0, ref_bright=1000 + ) + decoder.feed_bin(prep_marker, prep_blob) - def build_words(intensity): - words = [1, excitation * 10, emission * 10, 0, 0, 0, intensity, 0, 0, 0] - return words, intensity + def build_packet(intensity): + marker, blob = _flr_data_blob(excitation * 10, emission * 10, intensity, 1000) + return marker, blob, intensity def extract_actual(decoder): return decoder.intensities - self._run_decoder_case(decoder, build_words, extract_actual) + self._run_decoder_case(decoder, build_packet, extract_actual) def test_decode_luminescence_pattern(self): decoder = _LuminescenceRunDecoder(len(self.scan_wells)) - def build_words(intensity): - words = [1, 0, 0, 0, 0, 0, intensity, 0, 0] - return words, intensity + def build_packet(intensity): + marker, blob = _lum_data_blob(0, intensity) + return marker, blob, intensity def extract_actual(decoder): return [measurement.intensity for measurement in decoder.measurements] - self._run_decoder_case(decoder, build_words, extract_actual) + self._run_decoder_case(decoder, build_packet, extract_actual) class TestTecanInfiniteScanGeometry(unittest.TestCase): @@ -577,14 +630,8 @@ def test_frame_ascii_command(self): framed = TecanInfinite200ProBackend._frame_ascii_command("A") self.assertEqual(framed, b"\x02A\x03\x00\x00\x01\x40\x0d") - def test_decode_ascii_frames(self): - data = bytearray(b"\x02HELLO\x03\x00\x00\x05\x00\r\x02ST\x03\x00\x00\x02\x06\r") - frames = TecanInfinite200ProBackend._decode_ascii_frames(data) - self.assertEqual(frames, ["HELLO", "ST"]) - self.assertEqual(data, bytearray()) - def test_consume_leading_ascii_frame(self): - buffer = bytearray(b"\x02ST\x03\x00\rXYZ") + buffer = bytearray(TecanInfinite200ProBackend._frame_ascii_command("ST") + b"XYZ") consumed, text = _consume_leading_ascii_frame(buffer) self.assertTrue(consumed) self.assertEqual(text, "ST") From 9f49e31bc3581e321a43111c1e65949a4bae3f98 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 16:10:10 -0800 Subject: [PATCH 09/33] remove tecanscanconfig in favor of infinite --- pylabrobot/plate_reading/__init__.py | 2 +- pylabrobot/plate_reading/tecan_infinite_backend.py | 8 ++------ pylabrobot/plate_reading/tecan_infinite_backend_tests.py | 7 ++----- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index a8021bc5204..e5063cc1559 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -12,4 +12,4 @@ ImagingResult, Objective, ) -from .tecan_infinite_backend import TecanInfinite200ProBackend, TecanScanConfig +from .tecan_infinite_backend import InfiniteScanConfig, TecanInfinite200ProBackend diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index e66fe397adf..566882d6ce5 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -111,9 +111,6 @@ class InfiniteScanConfig: counts_per_mm_y: float = 1_000 -TecanScanConfig = InfiniteScanConfig - - def _u16be(payload: bytes, offset: int) -> int: return int.from_bytes(payload[offset : offset + 2], "big") @@ -1246,6 +1243,7 @@ def _is_terminal_frame(text: str) -> bool: """Return True if the ASCII frame is a terminal marker.""" return text in {"ST", "+", "-"} or text.startswith("BY#T") + @dataclass class _AbsorbanceMeasurement: sample: int @@ -1395,9 +1393,7 @@ def _handle_bin(self, marker: int, blob: bytes) -> None: ) else: intensity = int(round(sum(counts) / len(counts))) if counts else 0 - self.measurements.append( - _LuminescenceMeasurement(intensity=intensity) - ) + self.measurements.append(_LuminescenceMeasurement(intensity=intensity)) __all__ = [ diff --git a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py index cec8c52ab15..348c656dfc1 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend_tests.py @@ -3,8 +3,8 @@ from pylabrobot.plate_reading.tecan_infinite_backend import ( InfiniteScanConfig, TecanInfinite200ProBackend, - _AbsorbanceRunDecoder, _absorbance_od_calibrated, + _AbsorbanceRunDecoder, _consume_leading_ascii_frame, _FluorescenceRunDecoder, _LuminescenceRunDecoder, @@ -25,10 +25,7 @@ def _bin_blob(payload): def _abs_prepare_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): header = _pack_u16([0, ex_decitenth]) - item = ( - (0).to_bytes(4, "big") - + _pack_u16([0, 0, meas_dark, meas_bright, 0, ref_dark, ref_bright]) - ) + item = (0).to_bytes(4, "big") + _pack_u16([0, 0, meas_dark, meas_bright, 0, ref_dark, ref_bright]) return _bin_blob(header + item) From 9db0657fab900cad7dc3624e3f910ad0d511de93 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 16:17:32 -0800 Subject: [PATCH 10/33] Add size parameter to USB.read() for precise byte reading - USB._read_packet() now accepts optional size to read exact bytes from wire - USB.read() passes remaining byte count to _read_packet() when size specified - PyUSBInfiniteTransport.read() uses new size parameter instead of slicing - USBValidator.read() updated for interface consistency Co-Authored-By: Claude Opus 4.5 --- pylabrobot/io/usb.py | 29 ++++++++++++++----- .../plate_reading/tecan_infinite_backend.py | 5 ++-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pylabrobot/io/usb.py b/pylabrobot/io/usb.py index bf831730281..492fcd3fad8 100644 --- a/pylabrobot/io/usb.py +++ b/pylabrobot/io/usb.py @@ -125,35 +125,42 @@ async def write(self, data: bytes, timeout: Optional[float] = None): ) ) - def _read_packet(self) -> Optional[bytearray]: + def _read_packet(self, size: Optional[int] = None) -> Optional[bytearray]: """Read a packet from the machine. + Args: + size: The maximum number of bytes to read. If `None`, read up to wMaxPacketSize bytes. + Returns: - A string containing the decoded packet, or None if no packet was received. + A bytearray containing the data read, or None if no data was received. """ assert self.dev is not None and self.read_endpoint is not None, "Device not connected." + read_size = size if size is not None else self.read_endpoint.wMaxPacketSize + try: res = self.dev.read( self.read_endpoint, - self.read_endpoint.wMaxPacketSize, + read_size, timeout=int(self.packet_read_timeout * 1000), # timeout in ms ) if res is not None: - return bytearray(res) # convert res into text + return bytearray(res) return None except usb.core.USBError: # No data available (yet), this will give a timeout error. Don't reraise. return None - async def read(self, timeout: Optional[int] = None) -> bytes: + async def read(self, timeout: Optional[int] = None, size: Optional[int] = None) -> bytes: """Read a response from the device. Args: timeout: The timeout for reading from the device in seconds. If `None`, use the default timeout (specified by the `read_timeout` attribute). + size: The maximum number of bytes to read. If `None`, read all available data until no + more packets arrive (original behavior). """ assert self.read_endpoint is not None, "Device not connected." @@ -171,13 +178,16 @@ def read_or_timeout(): resp = bytearray() last_packet: Optional[bytearray] = None while True: # read while we have data, and while the last packet is the max size. - last_packet = self._read_packet() + remaining = size - len(resp) if size is not None else None + last_packet = self._read_packet(size=remaining) if last_packet is not None: resp += last_packet if self.read_endpoint is None: raise RuntimeError("Read endpoint is None. Call setup() first.") if last_packet is None or len(last_packet) != self.read_endpoint.wMaxPacketSize: break + if size is not None and len(resp) >= size: + break if len(resp) == 0: continue @@ -427,7 +437,7 @@ async def write(self, data: bytes, timeout: Optional[float] = None): align_sequences(expected=next_command.data, actual=decoded) raise ValidationError("Data mismatch: difference was written to stdout.") - async def read(self, timeout: Optional[float] = None) -> bytes: + async def read(self, timeout: Optional[float] = None, size: Optional[int] = None) -> bytes: next_command = USBCommand(**self.cr.next_command()) if not ( next_command.module == "usb" @@ -435,7 +445,10 @@ async def read(self, timeout: Optional[float] = None) -> bytes: and next_command.action == "read" ): raise ValidationError("next command is not read") - return next_command.data.encode() + data = next_command.data.encode() + if size is not None: + data = data[:size] + return data def ctrl_transfer( self, diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 566882d6ce5..7c2a4b9a065 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -97,9 +97,8 @@ async def write(self, data: bytes) -> None: async def read(self, size: int) -> bytes: if self._usb is None: raise RuntimeError("USB transport not opened.") - data = await self._usb.read() - b = bytes(data[:size]) - return b + data = await self._usb.read(size=size) + return bytes(data) @dataclass From 1e70e88b25a213830adbc50c063e2c9378492a7d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 16:23:37 -0800 Subject: [PATCH 11/33] Remove PyUSBInfiniteTransport, use USB directly in Infinite backend - Remove InfiniteTransport protocol and PyUSBInfiniteTransport class - TecanInfinite200ProBackend creates USB instance internally as self.io - Move reset logic into backend's _recover_transport method - Simplifies the transport layer by removing unnecessary abstraction Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan_infinite_backend.py | 108 +++--------------- 1 file changed, 18 insertions(+), 90 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 7c2a4b9a065..220e568f9e6 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -15,7 +15,7 @@ import time from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Dict, List, Optional, Protocol, Sequence, TextIO, Tuple +from typing import Dict, List, Optional, Sequence, TextIO, Tuple from pylabrobot.io.usb import USB from pylabrobot.plate_reading.backend import PlateReaderBackend @@ -26,81 +26,6 @@ BIN_RE = re.compile(r"^(\d+),BIN:$") -class InfiniteTransport(Protocol): - """Minimal transport required by the backend. - - Implementations are expected to wrap PyUSB/libusbK. - """ - - async def open(self) -> None: - """Open the transport connection.""" - ... - - async def close(self) -> None: - """Close the transport connection.""" - ... - - async def write(self, data: bytes) -> None: - """Send raw data to the transport.""" - ... - - async def read(self, size: int) -> bytes: - """Read raw data from the transport.""" - ... - - async def reset(self) -> None: - """Reset the transport connection.""" - ... - - -class PyUSBInfiniteTransport(InfiniteTransport): - """Transport that reuses pylabrobot.io.usb.USB for Infinite communication.""" - - def __init__( - self, - vendor_id: int = 0x0C47, - product_id: int = 0x8007, - packet_read_timeout: int = 3, - read_timeout: int = 30, - ) -> None: - self._vendor_id = vendor_id - self._product_id = product_id - self._usb: Optional[USB] = None - self._packet_read_timeout = packet_read_timeout - self._read_timeout = read_timeout - - async def open(self) -> None: - io = USB( - id_vendor=self._vendor_id, - id_product=self._product_id, - packet_read_timeout=self._packet_read_timeout, - read_timeout=self._read_timeout, - ) - await io.setup() - self._usb = io - - async def close(self) -> None: - if self._usb is not None: - await self._usb.stop() - self._usb = None - - async def reset(self) -> None: - await self.close() - await asyncio.sleep(0.2) - await self.open() - - async def write(self, data: bytes) -> None: - if self._usb is None or self._usb.write_endpoint is None: - raise RuntimeError("USB transport not opened.") - await self._usb.write(data) - - async def read(self, size: int) -> bytes: - if self._usb is None: - raise RuntimeError("USB transport not opened.") - data = await self._usb.read(size=size) - return bytes(data) - - @dataclass class InfiniteScanConfig: """Scan configuration for Infinite plate readers.""" @@ -580,14 +505,21 @@ class TecanInfinite200ProBackend(PlateReaderBackend): ], } + VENDOR_ID = 0x0C47 + PRODUCT_ID = 0x8007 + def __init__( self, - transport: Optional[InfiniteTransport] = None, scan_config: Optional[InfiniteScanConfig] = None, packet_log_path: Optional[str] = None, ) -> None: super().__init__() - self._transport = transport or PyUSBInfiniteTransport() + self.io = USB( + id_vendor=self.VENDOR_ID, + id_product=self.PRODUCT_ID, + packet_read_timeout=3, + read_timeout=30, + ) self.config = scan_config or InfiniteScanConfig() self._setup_lock = asyncio.Lock() self._ready = False @@ -611,7 +543,7 @@ async def setup(self) -> None: async with self._setup_lock: if self._ready: return - await self._transport.open() + await self.io.setup() await self._initialize_device() for mode in self._MODE_CAPABILITY_COMMANDS: if mode not in self._mode_capabilities: @@ -623,7 +555,7 @@ async def stop(self) -> None: if not self._ready: return await self._cleanup_protocol() - await self._transport.close() + await self.io.stop() if self._packet_log_handle is not None: self._packet_log_handle.close() self._packet_log_handle = None @@ -1054,7 +986,7 @@ def _reset_stream_state(self) -> None: async def _read_packet(self, size: int) -> bytes: try: - data = await self._transport.read(size) + data = await self.io.read(size=size) except TimeoutError: await self._recover_transport() raise @@ -1064,14 +996,11 @@ async def _read_packet(self, size: int) -> bytes: async def _recover_transport(self) -> None: try: - await self._transport.reset() + await self.io.stop() + await asyncio.sleep(0.2) + await self.io.setup() except Exception: - try: - await self._transport.close() - await asyncio.sleep(0.2) - await self._transport.open() - except Exception: - return + return self._device_initialized = False self._mode_capabilities.clear() self._reset_stream_state() @@ -1181,7 +1110,7 @@ async def _send_ascii( ) -> List[str]: logger.debug("[tecan] >> %s", command) framed = self._frame_ascii_command(command) - await self._transport.write(framed) + await self.io.write(framed) await self._log_packet("out", framed, ascii_payload=command) if not read_response: return [] @@ -1398,5 +1327,4 @@ def _handle_bin(self, marker: int, blob: bytes) -> None: __all__ = [ "TecanInfinite200ProBackend", "InfiniteScanConfig", - "PyUSBInfiniteTransport", ] From bbc0bd9f2820d4c6bd35fb99abd759aa5fbf190a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 19:43:01 -0800 Subject: [PATCH 12/33] Refactor Tecan Infinite backend to use io/binary Reader Replace custom _u16be, _u32be, _i32be helper functions with the Reader class from io/binary using little_endian=False for big-endian parsing. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan_infinite_backend.py | 110 +++++++++--------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan_infinite_backend.py index 220e568f9e6..381238e23eb 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend.py +++ b/pylabrobot/plate_reading/tecan_infinite_backend.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Sequence, TextIO, Tuple +from pylabrobot.io.binary import Reader from pylabrobot.io.usb import USB from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate @@ -35,18 +36,6 @@ class InfiniteScanConfig: counts_per_mm_y: float = 1_000 -def _u16be(payload: bytes, offset: int) -> int: - return int.from_bytes(payload[offset : offset + 2], "big") - - -def _u32be(payload: bytes, offset: int) -> int: - return int.from_bytes(payload[offset : offset + 4], "big") - - -def _i32be(payload: bytes, offset: int) -> int: - return int.from_bytes(payload[offset : offset + 4], "big", signed=True) - - def _integration_value_to_seconds(value: int) -> float: return value / 1_000_000.0 if value >= 1000 else value / 1000.0 @@ -63,8 +52,8 @@ def _split_payload_and_trailer(marker: int, blob: bytes) -> Optional[Tuple[bytes if len(blob) != marker + 4: return None payload = blob[:marker] - trailer = blob[marker:] - return payload, (_u16be(trailer, 0), _u16be(trailer, 2)) + trailer_reader = Reader(blob[marker:], little_endian=False) + return payload, (trailer_reader.u16(), trailer_reader.u16()) @dataclass(frozen=True) @@ -92,23 +81,23 @@ def _decode_abs_prepare(marker: int, blob: bytes) -> Optional[_AbsorbancePrepare payload, _ = split if len(payload) < 4 + 18: return None - ex = _u16be(payload, 2) - items_blob = payload[4:] - if len(items_blob) % 18 != 0: + if (len(payload) - 4) % 18 != 0: return None + reader = Reader(payload, little_endian=False) + reader.raw_bytes(2) # skip first 2 bytes + ex = reader.u16() items: List[_AbsorbancePrepareItem] = [] - for off in range(0, len(items_blob), 18): - item = items_blob[off : off + 18] + while reader.has_remaining(): items.append( _AbsorbancePrepareItem( - ticker_overflows=_u32be(item, 0), - ticker_counter=_u16be(item, 4), - meas_gain=_u16be(item, 6), - meas_dark=_u16be(item, 8), - meas_bright=_u16be(item, 10), - ref_gain=_u16be(item, 12), - ref_dark=_u16be(item, 14), - ref_bright=_u16be(item, 16), + ticker_overflows=reader.u32(), + ticker_counter=reader.u16(), + meas_gain=reader.u16(), + meas_dark=reader.u16(), + meas_bright=reader.u16(), + ref_gain=reader.u16(), + ref_dark=reader.u16(), + ref_bright=reader.u16(), ) ) return _AbsorbancePrepare(ex=ex, items=items) @@ -121,16 +110,16 @@ def _decode_abs_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[ payload, _ = split if len(payload) < 4: return None - label = _u16be(payload, 0) - ex = _u16be(payload, 2) - off = 4 + reader = Reader(payload, little_endian=False) + label = reader.u16() + ex = reader.u16() items: List[Tuple[int, int]] = [] - while off + 10 <= len(payload): - meas = _u16be(payload, off + 6) - ref = _u16be(payload, off + 8) + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) # skip first 6 bytes of each item + meas = reader.u16() + ref = reader.u16() items.append((meas, ref)) - off += 10 - if off != len(payload): + if reader.offset() != len(payload): return None return label, ex, items @@ -194,11 +183,18 @@ def _decode_flr_prepare(marker: int, blob: bytes) -> Optional[_FluorescencePrepa payload, _ = split if len(payload) != 18: return None + reader = Reader(payload, little_endian=False) + ex = reader.u16() + reader.raw_bytes(8) # skip bytes 2-9 + meas_dark = reader.u16() + reader.raw_bytes(2) # skip bytes 12-13 + ref_dark = reader.u16() + ref_bright = reader.u16() return _FluorescencePrepare( - ex=_u16be(payload, 0), - meas_dark=_u16be(payload, 10), - ref_dark=_u16be(payload, 14), - ref_bright=_u16be(payload, 16), + ex=ex, + meas_dark=meas_dark, + ref_dark=ref_dark, + ref_bright=ref_bright, ) @@ -211,17 +207,17 @@ def _decode_flr_data( payload, _ = split if len(payload) < 6: return None - label = _u16be(payload, 0) - ex = _u16be(payload, 2) - em = _u16be(payload, 4) - off = 6 + reader = Reader(payload, little_endian=False) + label = reader.u16() + ex = reader.u16() + em = reader.u16() items: List[Tuple[int, int]] = [] - while off + 10 <= len(payload): - meas = _u16be(payload, off + 6) - ref = _u16be(payload, off + 8) + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) # skip first 6 bytes of each item + meas = reader.u16() + ref = reader.u16() items.append((meas, ref)) - off += 10 - if off != len(payload): + if reader.offset() != len(payload): return None return label, ex, em, items @@ -252,7 +248,9 @@ def _decode_lum_prepare(marker: int, blob: bytes) -> Optional[_LuminescencePrepa payload, _ = split if len(payload) != 10: return None - return _LuminescencePrepare(ref_dark=_i32be(payload, 6)) + reader = Reader(payload, little_endian=False) + reader.raw_bytes(6) # skip bytes 0-5 + return _LuminescencePrepare(ref_dark=reader.i32()) def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: @@ -262,14 +260,14 @@ def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[ payload, _ = split if len(payload) < 4: return None - label = _u16be(payload, 0) - em = _u16be(payload, 2) - off = 4 + reader = Reader(payload, little_endian=False) + label = reader.u16() + em = reader.u16() counts: List[int] = [] - while off + 10 <= len(payload): - counts.append(_i32be(payload, off + 6)) - off += 10 - if off != len(payload): + while reader.offset() + 10 <= len(payload): + reader.raw_bytes(6) # skip first 6 bytes of each item + counts.append(reader.i32()) + if reader.offset() != len(payload): return None return label, em, counts From 0af381dd93069ac9e1a68c22b2680c1b59a08450 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 19:45:32 -0800 Subject: [PATCH 13/33] move into tecan package --- pylabrobot/plate_reading/__init__.py | 2 +- pylabrobot/plate_reading/tecan/__init__.py | 1 + .../{tecan_infinite_backend.py => tecan/infinite_backend.py} | 0 .../infinite_backend_tests.py} | 2 +- 4 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 pylabrobot/plate_reading/tecan/__init__.py rename pylabrobot/plate_reading/{tecan_infinite_backend.py => tecan/infinite_backend.py} (100%) rename pylabrobot/plate_reading/{tecan_infinite_backend_tests.py => tecan/infinite_backend_tests.py} (99%) diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index 57083ff16ca..7f5f18f184c 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -44,4 +44,4 @@ ImagingResult, Objective, ) -from .tecan_infinite_backend import InfiniteScanConfig, TecanInfinite200ProBackend +from .tecan import InfiniteScanConfig, TecanInfinite200ProBackend diff --git a/pylabrobot/plate_reading/tecan/__init__.py b/pylabrobot/plate_reading/tecan/__init__.py new file mode 100644 index 00000000000..f4e32fe60c2 --- /dev/null +++ b/pylabrobot/plate_reading/tecan/__init__.py @@ -0,0 +1 @@ +from .infinite_backend import InfiniteScanConfig, TecanInfinite200ProBackend diff --git a/pylabrobot/plate_reading/tecan_infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py similarity index 100% rename from pylabrobot/plate_reading/tecan_infinite_backend.py rename to pylabrobot/plate_reading/tecan/infinite_backend.py diff --git a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py similarity index 99% rename from pylabrobot/plate_reading/tecan_infinite_backend_tests.py rename to pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 348c656dfc1..0cfe85215e1 100644 --- a/pylabrobot/plate_reading/tecan_infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.plate_reading.tecan_infinite_backend import ( +from pylabrobot.plate_reading.tecan.infinite_backend import ( InfiniteScanConfig, TecanInfinite200ProBackend, _absorbance_od_calibrated, From 0219a2cb217c0b6e2fe03cefd66d12aceec23320 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 21:12:18 -0800 Subject: [PATCH 14/33] Simplify _end_run to use _active_step_loss_commands directly Remove redundant step_loss local variable and parameter since _end_run can access self._active_step_loss_commands directly. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 381238e23eb..3bee856e24a 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -583,8 +583,7 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - step_loss = ["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"] - self._active_step_loss_commands = list(step_loss) + self._active_step_loss_commands = ["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"] self._active_mode = "ABS" await self._begin_run() try: @@ -631,7 +630,7 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int } ] finally: - await self._end_run(step_loss) + await self._end_run() async def _configure_absorbance(self, wavelength_nm: int) -> None: wl_decitenth = int(round(wavelength_nm * 10)) @@ -690,8 +689,11 @@ async def read_fluorescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - step_loss = ["CHECK MTP.STEPLOSS", "CHECK FI.TOP.STEPLOSS", "CHECK FI.STEPLOSS.Z"] - self._active_step_loss_commands = list(step_loss) + self._active_step_loss_commands = [ + "CHECK MTP.STEPLOSS", + "CHECK FI.TOP.STEPLOSS", + "CHECK FI.STEPLOSS.Z", + ] self._active_mode = "FI.TOP" await self._begin_run() try: @@ -738,7 +740,7 @@ async def read_fluorescence( } ] finally: - await self._end_run(step_loss) + await self._end_run() async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> None: ex_decitenth = int(round(excitation_nm * 10)) @@ -798,8 +800,7 @@ async def read_luminescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) - step_loss = ["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"] - self._active_step_loss_commands = list(step_loss) + self._active_step_loss_commands = ["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"] self._active_mode = "LUM" await self._begin_run() try: @@ -844,7 +845,7 @@ async def read_luminescence( } ] finally: - await self._end_run(step_loss) + await self._end_run() async def _await_measurements( self, decoder: "_MeasurementDecoder", row_count: int, mode: str @@ -1025,10 +1026,10 @@ async def _log_packet( self._packet_log_handle.write(json.dumps(record) + "\n") self._packet_log_handle.flush() - async def _end_run(self, step_loss_commands: Sequence[str]) -> None: + async def _end_run(self) -> None: try: await self._send_ascii("TERMINATE", allow_timeout=True) - for cmd in step_loss_commands: + for cmd in self._active_step_loss_commands: await self._send_ascii(cmd, allow_timeout=True) await self._send_ascii("KEYLOCK OFF", allow_timeout=True) await self._send_ascii("ABSOLUTE MTP,IN", allow_timeout=True) From 0de19f2c5b1b67909f93575c19123ad6c3fe8c35 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 21:20:14 -0800 Subject: [PATCH 15/33] Simplify command sending in configure and cleanup methods - Inline command lists in _configure_absorbance, _configure_fluorescence, and _configure_luminescence with direct _send_ascii calls - Refactor _cleanup_protocol to use a local helper function instead of building a commands list Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 166 ++++++++---------- 1 file changed, 76 insertions(+), 90 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 3bee856e24a..750d150c72a 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -638,32 +638,25 @@ async def _configure_absorbance(self, wavelength_nm: int) -> None: reads_number = max(1, int(self.config.flashes)) await self._send_ascii("MODE ABS") - - commands = [ - "EXCITATION CLEAR", - "TIME CLEAR", - "GAIN CLEAR", - "READS CLEAR", - "POSITION CLEAR", - "MIRROR CLEAR", - f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", - f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", - f"READS 0,NUMBER={reads_number}", - f"READS 1,NUMBER={reads_number}", - "TIME 0,READDELAY=0", - "TIME 1,READDELAY=0", - "SCAN DIRECTION=ALTUP", - "#RATIO LABELS", - f"BEAM DIAMETER={self._capability_numeric('ABS', '#BEAM DIAMETER', 700)}", - "RATIO LABELS=1", - "PREPARE REF", - ] - - for cmd in commands: - if cmd == "PREPARE REF": - await self._send_ascii(cmd, allow_timeout=True, read_response=False) - else: - await self._send_ascii(cmd, allow_timeout=True) + await self._send_ascii("EXCITATION CLEAR", allow_timeout=True) + await self._send_ascii("TIME CLEAR", allow_timeout=True) + await self._send_ascii("GAIN CLEAR", allow_timeout=True) + await self._send_ascii("READS CLEAR", allow_timeout=True) + await self._send_ascii("POSITION CLEAR", allow_timeout=True) + await self._send_ascii("MIRROR CLEAR", allow_timeout=True) + await self._send_ascii(f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True) + await self._send_ascii(f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True) + await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self._send_ascii("TIME 0,READDELAY=0", allow_timeout=True) + await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_ascii("SCAN DIRECTION=ALTUP", allow_timeout=True) + await self._send_ascii("#RATIO LABELS", allow_timeout=True) + await self._send_ascii( + f"BEAM DIAMETER={self._capability_numeric('ABS', '#BEAM DIAMETER', 700)}", allow_timeout=True + ) + await self._send_ascii("RATIO LABELS=1", allow_timeout=True) + await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) def _auto_bandwidth(self, wavelength_nm: int) -> float: """Return bandwidth in nm based on Infinite M specification.""" @@ -748,43 +741,40 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> self._current_fluorescence_excitation = ex_decitenth self._current_fluorescence_emission = em_decitenth reads_number = max(1, int(self.config.flashes)) - clear_cmds = [ - "MODE FI.TOP", - "READS CLEAR", - "EXCITATION CLEAR", - "EMISSION CLEAR", - "TIME CLEAR", - "GAIN CLEAR", - "POSITION CLEAR", - "MIRROR CLEAR", - ] - configure_cmds = [ - f"EXCITATION 0,FI,{ex_decitenth},50,0", - f"EMISSION 0,FI,{em_decitenth},200,0", - "TIME 0,INTEGRATION=20", - "TIME 0,LAG=0", - "TIME 0,READDELAY=0", - "GAIN 0,VALUE=100", - "POSITION 0,Z=20000", - f"BEAM DIAMETER={self._capability_numeric('FI.TOP', '#BEAM DIAMETER', 3000)}", - "SCAN DIRECTION=UP", - "RATIO LABELS=1", - f"READS 0,NUMBER={reads_number}", - f"EXCITATION 1,FI,{ex_decitenth},50,0", - f"EMISSION 1,FI,{em_decitenth},200,0", - "TIME 1,INTEGRATION=20", - "TIME 1,LAG=0", - "TIME 1,READDELAY=0", - "GAIN 1,VALUE=100", - "POSITION 1,Z=20000", - f"READS 1,NUMBER={reads_number}", - ] + beam_diameter = self._capability_numeric("FI.TOP", "#BEAM DIAMETER", 3000) + # UI issues the entire FI configuration twice before PREPARE REF. for _ in range(2): - for cmd in clear_cmds: - await self._send_ascii(cmd, allow_timeout=True) - for cmd in configure_cmds: - await self._send_ascii(cmd, allow_timeout=True) + # clear commands + await self._send_ascii("MODE FI.TOP", allow_timeout=True) + await self._send_ascii("READS CLEAR", allow_timeout=True) + await self._send_ascii("EXCITATION CLEAR", allow_timeout=True) + await self._send_ascii("EMISSION CLEAR", allow_timeout=True) + await self._send_ascii("TIME CLEAR", allow_timeout=True) + await self._send_ascii("GAIN CLEAR", allow_timeout=True) + await self._send_ascii("POSITION CLEAR", allow_timeout=True) + await self._send_ascii("MIRROR CLEAR", allow_timeout=True) + + # configure commands + await self._send_ascii(f"EXCITATION 0,FI,{ex_decitenth},50,0", allow_timeout=True) + await self._send_ascii(f"EMISSION 0,FI,{em_decitenth},200,0", allow_timeout=True) + await self._send_ascii("TIME 0,INTEGRATION=20", allow_timeout=True) + await self._send_ascii("TIME 0,LAG=0", allow_timeout=True) + await self._send_ascii("TIME 0,READDELAY=0", allow_timeout=True) + await self._send_ascii("GAIN 0,VALUE=100", allow_timeout=True) + await self._send_ascii("POSITION 0,Z=20000", allow_timeout=True) + await self._send_ascii(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) + await self._send_ascii("SCAN DIRECTION=UP", allow_timeout=True) + await self._send_ascii("RATIO LABELS=1", allow_timeout=True) + await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_ascii(f"EXCITATION 1,FI,{ex_decitenth},50,0", allow_timeout=True) + await self._send_ascii(f"EMISSION 1,FI,{em_decitenth},200,0", allow_timeout=True) + await self._send_ascii("TIME 1,INTEGRATION=20", allow_timeout=True) + await self._send_ascii("TIME 1,LAG=0", allow_timeout=True) + await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_ascii("GAIN 1,VALUE=100", allow_timeout=True) + await self._send_ascii("POSITION 1,Z=20000", allow_timeout=True) + await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) async def read_luminescence( @@ -882,30 +872,23 @@ async def _configure_luminescence(self) -> None: 0: _integration_value_to_seconds(3_000_000), 1: _integration_value_to_seconds(1_000_000), } - commands = [ - "READS CLEAR", - "EMISSION CLEAR", - "TIME CLEAR", - "GAIN CLEAR", - "POSITION CLEAR", - "MIRROR CLEAR", - "POSITION LUM,Z=14620", - "TIME 0,INTEGRATION=3000000", - f"READS 0,NUMBER={reads_number}", - "SCAN DIRECTION=UP", - "RATIO LABELS=1", - "EMISSION 1,EMPTY,0,0,0", - "TIME 1,INTEGRATION=1000000", - "TIME 1,READDELAY=0", - f"READS 1,NUMBER={reads_number}", - "#EMISSION ATTENUATION", - "PREPARE REF", - ] - for cmd in commands: - if cmd == "PREPARE REF": - await self._send_ascii(cmd, allow_timeout=True, read_response=False) - else: - await self._send_ascii(cmd, allow_timeout=True) + await self._send_ascii("READS CLEAR", allow_timeout=True) + await self._send_ascii("EMISSION CLEAR", allow_timeout=True) + await self._send_ascii("TIME CLEAR", allow_timeout=True) + await self._send_ascii("GAIN CLEAR", allow_timeout=True) + await self._send_ascii("POSITION CLEAR", allow_timeout=True) + await self._send_ascii("MIRROR CLEAR", allow_timeout=True) + await self._send_ascii("POSITION LUM,Z=14620", allow_timeout=True) + await self._send_ascii("TIME 0,INTEGRATION=3000000", allow_timeout=True) + await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_ascii("SCAN DIRECTION=UP", allow_timeout=True) + await self._send_ascii("RATIO LABELS=1", allow_timeout=True) + await self._send_ascii("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) + await self._send_ascii("TIME 1,INTEGRATION=1000000", allow_timeout=True) + await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self._send_ascii("#EMISSION ATTENUATION", allow_timeout=True) + await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) def _group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: grouped: Dict[int, List[Well]] = {} @@ -1039,15 +1022,18 @@ async def _end_run(self) -> None: self._active_mode = None async def _cleanup_protocol(self) -> None: - if not self._run_active and not self._active_step_loss_commands: - commands = ["KEYLOCK OFF", "ABSOLUTE MTP,IN"] - else: - commands = ["TERMINATE", *self._active_step_loss_commands, "KEYLOCK OFF", "ABSOLUTE MTP,IN"] - for cmd in commands: + async def send_cleanup_cmd(cmd: str) -> None: try: await self._send_ascii(cmd, allow_timeout=True, read_response=False) except Exception: logger.warning("Cleanup command failed: %s", cmd) + + if self._run_active or self._active_step_loss_commands: + await send_cleanup_cmd("TERMINATE") + for cmd in self._active_step_loss_commands: + await send_cleanup_cmd(cmd) + await send_cleanup_cmd("KEYLOCK OFF") + await send_cleanup_cmd("ABSOLUTE MTP,IN") self._run_active = False self._active_step_loss_commands = [] self._active_mode = None From cb6336e838232b933b69f1ba7098f7963d115010 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 20 Jan 2026 21:33:16 -0800 Subject: [PATCH 16/33] Simplify method names by removing redundant "ascii" qualifier Rename: - _send_ascii -> _send_command - _frame_ascii_command -> _frame_command - _drain_ascii -> _drain - _read_ascii_response -> _read_command_response - _ascii_parser -> _parser Also update related docstrings. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 220 +++++++++--------- .../tecan/infinite_backend_tests.py | 6 +- 2 files changed, 115 insertions(+), 111 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 750d150c72a..ba1fb897e35 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -529,7 +529,7 @@ def __init__( self._current_fluorescence_emission: Optional[int] = None self._lum_integration_s: Dict[int, float] = {} self._pending_bin_events: List[Tuple[int, bytes]] = [] - self._ascii_parser = _StreamParser(allow_bare_ascii=True) + self._parser = _StreamParser(allow_bare_ascii=True) self._run_active = False self._active_step_loss_commands: List[str] = [] self._active_mode: Optional[str] = None @@ -565,14 +565,14 @@ async def stop(self) -> None: async def open(self) -> None: """Open the reader drawer.""" - await self._send_ascii("ABSOLUTE MTP,OUT") - await self._send_ascii("BY#T5000") + await self._send_command("ABSOLUTE MTP,OUT") + await self._send_command("BY#T5000") async def close(self, plate: Optional[Plate]) -> None: # noqa: ARG002 """Close the reader drawer.""" - await self._send_ascii("ABSOLUTE MTP,IN") - await self._send_ascii("BY#T5000") + await self._send_command("ABSOLUTE MTP,IN") + await self._send_command("BY#T5000") async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: """Queue and execute an absorbance scan.""" @@ -594,9 +594,9 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) _, y_stage = self._map_well_to_stage(row_wells[0]) - await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_ascii("SCAN DIRECTION=ALTUP") - await self._send_ascii( + await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_command("SCAN DIRECTION=ALTUP") + await self._send_command( f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False ) logger.info( @@ -637,26 +637,30 @@ async def _configure_absorbance(self, wavelength_nm: int) -> None: bw_decitenth = int(round(self._auto_bandwidth(wavelength_nm) * 10)) reads_number = max(1, int(self.config.flashes)) - await self._send_ascii("MODE ABS") - await self._send_ascii("EXCITATION CLEAR", allow_timeout=True) - await self._send_ascii("TIME CLEAR", allow_timeout=True) - await self._send_ascii("GAIN CLEAR", allow_timeout=True) - await self._send_ascii("READS CLEAR", allow_timeout=True) - await self._send_ascii("POSITION CLEAR", allow_timeout=True) - await self._send_ascii("MIRROR CLEAR", allow_timeout=True) - await self._send_ascii(f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True) - await self._send_ascii(f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True) - await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii("TIME 0,READDELAY=0", allow_timeout=True) - await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_ascii("SCAN DIRECTION=ALTUP", allow_timeout=True) - await self._send_ascii("#RATIO LABELS", allow_timeout=True) - await self._send_ascii( + await self._send_command("MODE ABS") + await self._send_command("EXCITATION CLEAR", allow_timeout=True) + await self._send_command("TIME CLEAR", allow_timeout=True) + await self._send_command("GAIN CLEAR", allow_timeout=True) + await self._send_command("READS CLEAR", allow_timeout=True) + await self._send_command("POSITION CLEAR", allow_timeout=True) + await self._send_command("MIRROR CLEAR", allow_timeout=True) + await self._send_command( + f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True + ) + await self._send_command( + f"EXCITATION 1,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True + ) + await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self._send_command("TIME 0,READDELAY=0", allow_timeout=True) + await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_command("SCAN DIRECTION=ALTUP", allow_timeout=True) + await self._send_command("#RATIO LABELS", allow_timeout=True) + await self._send_command( f"BEAM DIAMETER={self._capability_numeric('ABS', '#BEAM DIAMETER', 700)}", allow_timeout=True ) - await self._send_ascii("RATIO LABELS=1", allow_timeout=True) - await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) + await self._send_command("RATIO LABELS=1", allow_timeout=True) + await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) def _auto_bandwidth(self, wavelength_nm: int) -> float: """Return bandwidth in nm based on Infinite M specification.""" @@ -703,9 +707,9 @@ async def read_fluorescence( start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) _, y_stage = self._map_well_to_stage(row_wells[0]) - await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_ascii("SCAN DIRECTION=UP") - await self._send_ascii( + await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_command("SCAN DIRECTION=UP") + await self._send_command( f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False ) logger.info( @@ -746,36 +750,36 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> # UI issues the entire FI configuration twice before PREPARE REF. for _ in range(2): # clear commands - await self._send_ascii("MODE FI.TOP", allow_timeout=True) - await self._send_ascii("READS CLEAR", allow_timeout=True) - await self._send_ascii("EXCITATION CLEAR", allow_timeout=True) - await self._send_ascii("EMISSION CLEAR", allow_timeout=True) - await self._send_ascii("TIME CLEAR", allow_timeout=True) - await self._send_ascii("GAIN CLEAR", allow_timeout=True) - await self._send_ascii("POSITION CLEAR", allow_timeout=True) - await self._send_ascii("MIRROR CLEAR", allow_timeout=True) + await self._send_command("MODE FI.TOP", allow_timeout=True) + await self._send_command("READS CLEAR", allow_timeout=True) + await self._send_command("EXCITATION CLEAR", allow_timeout=True) + await self._send_command("EMISSION CLEAR", allow_timeout=True) + await self._send_command("TIME CLEAR", allow_timeout=True) + await self._send_command("GAIN CLEAR", allow_timeout=True) + await self._send_command("POSITION CLEAR", allow_timeout=True) + await self._send_command("MIRROR CLEAR", allow_timeout=True) # configure commands - await self._send_ascii(f"EXCITATION 0,FI,{ex_decitenth},50,0", allow_timeout=True) - await self._send_ascii(f"EMISSION 0,FI,{em_decitenth},200,0", allow_timeout=True) - await self._send_ascii("TIME 0,INTEGRATION=20", allow_timeout=True) - await self._send_ascii("TIME 0,LAG=0", allow_timeout=True) - await self._send_ascii("TIME 0,READDELAY=0", allow_timeout=True) - await self._send_ascii("GAIN 0,VALUE=100", allow_timeout=True) - await self._send_ascii("POSITION 0,Z=20000", allow_timeout=True) - await self._send_ascii(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) - await self._send_ascii("SCAN DIRECTION=UP", allow_timeout=True) - await self._send_ascii("RATIO LABELS=1", allow_timeout=True) - await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii(f"EXCITATION 1,FI,{ex_decitenth},50,0", allow_timeout=True) - await self._send_ascii(f"EMISSION 1,FI,{em_decitenth},200,0", allow_timeout=True) - await self._send_ascii("TIME 1,INTEGRATION=20", allow_timeout=True) - await self._send_ascii("TIME 1,LAG=0", allow_timeout=True) - await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_ascii("GAIN 1,VALUE=100", allow_timeout=True) - await self._send_ascii("POSITION 1,Z=20000", allow_timeout=True) - await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) + await self._send_command(f"EXCITATION 0,FI,{ex_decitenth},50,0", allow_timeout=True) + await self._send_command(f"EMISSION 0,FI,{em_decitenth},200,0", allow_timeout=True) + await self._send_command("TIME 0,INTEGRATION=20", allow_timeout=True) + await self._send_command("TIME 0,LAG=0", allow_timeout=True) + await self._send_command("TIME 0,READDELAY=0", allow_timeout=True) + await self._send_command("GAIN 0,VALUE=100", allow_timeout=True) + await self._send_command("POSITION 0,Z=20000", allow_timeout=True) + await self._send_command(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) + await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) + await self._send_command("RATIO LABELS=1", allow_timeout=True) + await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_command(f"EXCITATION 1,FI,{ex_decitenth},50,0", allow_timeout=True) + await self._send_command(f"EMISSION 1,FI,{em_decitenth},200,0", allow_timeout=True) + await self._send_command("TIME 1,INTEGRATION=20", allow_timeout=True) + await self._send_command("TIME 1,LAG=0", allow_timeout=True) + await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_command("GAIN 1,VALUE=100", allow_timeout=True) + await self._send_command("POSITION 1,Z=20000", allow_timeout=True) + await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) async def read_luminescence( self, @@ -807,9 +811,9 @@ async def read_luminescence( start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=False) _, y_stage = self._map_well_to_stage(row_wells[0]) - await self._send_ascii(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_ascii("SCAN DIRECTION=UP") - await self._send_ascii( + await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_command("SCAN DIRECTION=UP") + await self._send_command( f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False ) logger.info( @@ -858,37 +862,37 @@ async def _await_measurements( async def _await_scan_terminal(self, saw_terminal: bool) -> None: if saw_terminal: return - await self._read_ascii_response() + await self._read_command_response() async def _configure_luminescence(self) -> None: - await self._send_ascii("MODE LUM") + await self._send_command("MODE LUM") # Pre-flight safety checks observed in captures (queries omitted). - await self._send_ascii("CHECK LUM.FIBER") - await self._send_ascii("CHECK LUM.LID") - await self._send_ascii("CHECK LUM.STEPLOSS") - await self._send_ascii("MODE LUM") + await self._send_command("CHECK LUM.FIBER") + await self._send_command("CHECK LUM.LID") + await self._send_command("CHECK LUM.STEPLOSS") + await self._send_command("MODE LUM") reads_number = max(1, int(self.config.flashes)) self._lum_integration_s = { 0: _integration_value_to_seconds(3_000_000), 1: _integration_value_to_seconds(1_000_000), } - await self._send_ascii("READS CLEAR", allow_timeout=True) - await self._send_ascii("EMISSION CLEAR", allow_timeout=True) - await self._send_ascii("TIME CLEAR", allow_timeout=True) - await self._send_ascii("GAIN CLEAR", allow_timeout=True) - await self._send_ascii("POSITION CLEAR", allow_timeout=True) - await self._send_ascii("MIRROR CLEAR", allow_timeout=True) - await self._send_ascii("POSITION LUM,Z=14620", allow_timeout=True) - await self._send_ascii("TIME 0,INTEGRATION=3000000", allow_timeout=True) - await self._send_ascii(f"READS 0,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii("SCAN DIRECTION=UP", allow_timeout=True) - await self._send_ascii("RATIO LABELS=1", allow_timeout=True) - await self._send_ascii("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) - await self._send_ascii("TIME 1,INTEGRATION=1000000", allow_timeout=True) - await self._send_ascii("TIME 1,READDELAY=0", allow_timeout=True) - await self._send_ascii(f"READS 1,NUMBER={reads_number}", allow_timeout=True) - await self._send_ascii("#EMISSION ATTENUATION", allow_timeout=True) - await self._send_ascii("PREPARE REF", allow_timeout=True, read_response=False) + await self._send_command("READS CLEAR", allow_timeout=True) + await self._send_command("EMISSION CLEAR", allow_timeout=True) + await self._send_command("TIME CLEAR", allow_timeout=True) + await self._send_command("GAIN CLEAR", allow_timeout=True) + await self._send_command("POSITION CLEAR", allow_timeout=True) + await self._send_command("MIRROR CLEAR", allow_timeout=True) + await self._send_command("POSITION LUM,Z=14620", allow_timeout=True) + await self._send_command("TIME 0,INTEGRATION=3000000", allow_timeout=True) + await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) + await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) + await self._send_command("RATIO LABELS=1", allow_timeout=True) + await self._send_command("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) + await self._send_command("TIME 1,INTEGRATION=1000000", allow_timeout=True) + await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) + await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) + await self._send_command("#EMISSION ATTENUATION", allow_timeout=True) + await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) def _group_by_row(self, wells: Sequence[Well]) -> List[Tuple[int, List[Well]]]: grouped: Dict[int, List[Well]] = {} @@ -950,21 +954,21 @@ async def _initialize_device(self) -> None: if self._device_initialized: return try: - await self._send_ascii("QQ") + await self._send_command("QQ") except TimeoutError: logger.warning("QQ produced no response; continuing with initialization.") - await self._send_ascii("INIT FORCE") + await self._send_command("INIT FORCE") self._device_initialized = True async def _begin_run(self) -> None: await self._initialize_device() self._reset_stream_state() - await self._send_ascii("KEYLOCK ON") + await self._send_command("KEYLOCK ON") self._run_active = True def _reset_stream_state(self) -> None: self._pending_bin_events.clear() - self._ascii_parser = _StreamParser(allow_bare_ascii=True) + self._parser = _StreamParser(allow_bare_ascii=True) async def _read_packet(self, size: int) -> bytes: try: @@ -1011,11 +1015,11 @@ async def _log_packet( async def _end_run(self) -> None: try: - await self._send_ascii("TERMINATE", allow_timeout=True) + await self._send_command("TERMINATE", allow_timeout=True) for cmd in self._active_step_loss_commands: - await self._send_ascii(cmd, allow_timeout=True) - await self._send_ascii("KEYLOCK OFF", allow_timeout=True) - await self._send_ascii("ABSOLUTE MTP,IN", allow_timeout=True) + await self._send_command(cmd, allow_timeout=True) + await self._send_command("KEYLOCK OFF", allow_timeout=True) + await self._send_command("ABSOLUTE MTP,IN", allow_timeout=True) finally: self._run_active = False self._active_step_loss_commands = [] @@ -1024,7 +1028,7 @@ async def _end_run(self) -> None: async def _cleanup_protocol(self) -> None: async def send_cleanup_cmd(cmd: str) -> None: try: - await self._send_ascii(cmd, allow_timeout=True, read_response=False) + await self._send_command(cmd, allow_timeout=True, read_response=False) except Exception: logger.warning("Cleanup command failed: %s", cmd) @@ -1043,14 +1047,14 @@ async def _query_mode_capabilities(self, mode: str) -> None: if not commands: return try: - await self._send_ascii(f"MODE {mode}") + await self._send_command(f"MODE {mode}") except TimeoutError: logger.warning("Capability MODE %s timed out; continuing without mode capabilities.", mode) return collected: Dict[str, str] = {} for cmd in commands: try: - frames = await self._send_ascii(cmd) + frames = await self._send_command(cmd) except TimeoutError: logger.warning("Capability query '%s' timed out; proceeding with defaults.", cmd) continue @@ -1075,8 +1079,8 @@ def _capability_numeric(self, mode: str, command: str, fallback: int) -> int: return fallback @staticmethod - def _frame_ascii_command(command: str) -> bytes: - """Return a framed ASCII payload with length/checksum trailer.""" + def _frame_command(command: str) -> bytes: + """Return a framed command with length/checksum trailer.""" payload = command.encode("ascii") xor = 0 @@ -1086,7 +1090,7 @@ def _frame_ascii_command(command: str) -> bytes: length = len(payload) & 0xFF return b"\x02" + payload + b"\x03\x00\x00" + bytes([length, checksum]) + b"\x0d" - async def _send_ascii( + async def _send_command( self, command: str, wait_for_terminal: bool = True, @@ -1094,21 +1098,21 @@ async def _send_ascii( read_response: bool = True, ) -> List[str]: logger.debug("[tecan] >> %s", command) - framed = self._frame_ascii_command(command) + framed = self._frame_command(command) await self.io.write(framed) await self._log_packet("out", framed, ascii_payload=command) if not read_response: return [] if command.startswith(("#", "?")): try: - return await self._read_ascii_response(require_terminal=False) + return await self._read_command_response(require_terminal=False) except TimeoutError: if allow_timeout: logger.warning("Timeout waiting for response to %s", command) return [] raise try: - frames = await self._read_ascii_response(require_terminal=wait_for_terminal) + frames = await self._read_command_response(require_terminal=wait_for_terminal) except TimeoutError: if allow_timeout: logger.warning("Timeout waiting for response to %s", command) @@ -1118,37 +1122,37 @@ async def _send_ascii( logger.debug("[tecan] << %s", pkt) return frames - async def _drain_ascii(self, attempts: int = 4) -> None: - """Read and discard a few ASCII packets to clear the stream.""" + async def _drain(self, attempts: int = 4) -> None: + """Read and discard a few packets to clear the stream.""" for _ in range(attempts): data = await self._read_packet(128) if not data: break - async def _read_ascii_response( + async def _read_command_response( self, max_iterations: int = 8, require_terminal: bool = True ) -> List[str]: - """Read ASCII frames and cache any binary payloads that arrive.""" + """Read response frames and cache any binary payloads that arrive.""" frames: List[str] = [] saw_terminal = False for _ in range(max_iterations): chunk = await self._read_packet(128) if not chunk: break - for event in self._ascii_parser.feed(chunk): + for event in self._parser.feed(chunk): if event.text is not None: frames.append(event.text) if self._is_terminal_frame(event.text): saw_terminal = True elif event.marker is not None and event.blob is not None: self._pending_bin_events.append((event.marker, event.blob)) - if not require_terminal and frames and not self._ascii_parser.has_pending_bin(): + if not require_terminal and frames and not self._parser.has_pending_bin(): break - if require_terminal and saw_terminal and not self._ascii_parser.has_pending_bin(): + if require_terminal and saw_terminal and not self._parser.has_pending_bin(): break if require_terminal and not saw_terminal: # best effort: drain once more so pending ST doesn't leak into next command - await self._drain_ascii(1) + await self._drain(1) return frames @staticmethod diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 0cfe85215e1..31110c93037 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -623,12 +623,12 @@ def test_map_well_to_stage(self): class TestTecanInfiniteAscii(unittest.TestCase): - def test_frame_ascii_command(self): - framed = TecanInfinite200ProBackend._frame_ascii_command("A") + def test_frame_command(self): + framed = TecanInfinite200ProBackend._frame_command("A") self.assertEqual(framed, b"\x02A\x03\x00\x00\x01\x40\x0d") def test_consume_leading_ascii_frame(self): - buffer = bytearray(TecanInfinite200ProBackend._frame_ascii_command("ST") + b"XYZ") + buffer = bytearray(TecanInfinite200ProBackend._frame_command("ST") + b"XYZ") consumed, text = _consume_leading_ascii_frame(buffer) self.assertTrue(consumed) self.assertEqual(text, "ST") From 4448f0a73ab2a35adfbf678fd7a9040346d90ce2 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 11:08:08 -0800 Subject: [PATCH 17/33] Add command verification tests for Tecan Infinite backend Tests verify that open, close, read_absorbance, read_fluorescence, and read_luminescence send the correct USB commands in the correct order. Co-Authored-By: Claude Opus 4.5 --- .../tecan/infinite_backend_tests.py | 247 +++++++++++++++++- 1 file changed, 246 insertions(+), 1 deletion(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 31110c93037..9ed9b427f29 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -1,5 +1,7 @@ import unittest +from unittest.mock import AsyncMock, call, patch +from pylabrobot.io.usb import USB from pylabrobot.plate_reading.tecan.infinite_backend import ( InfiniteScanConfig, TecanInfinite200ProBackend, @@ -44,7 +46,7 @@ def _flr_data_blob(ex_decitenth, em_decitenth, meas, ref): return _bin_blob(_pack_u16(words)) -def _lum_data_blob(em_decitenth, intensity): +def _lum_data_blob(em_decitenth: int, intensity: int): payload = bytearray(14) payload[0:2] = (0).to_bytes(2, "big") payload[2:4] = int(em_decitenth).to_bytes(2, "big") @@ -640,3 +642,246 @@ def test_terminal_frames(self): self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("-")) self.assertTrue(TecanInfinite200ProBackend._is_terminal_frame("BY#T5000")) self.assertFalse(TecanInfinite200ProBackend._is_terminal_frame("OK")) + + +class TestTecanInfiniteCommands(unittest.IsolatedAsyncioTestCase): + """Tests that verify correct commands are sent to the device.""" + + def setUp(self): + self.mock_usb = AsyncMock(spec=USB) + self.mock_usb.setup = AsyncMock() + self.mock_usb.stop = AsyncMock() + self.mock_usb.write = AsyncMock() + # Default to returning terminal response + self.mock_usb.read = AsyncMock(return_value=self._frame("ST")) + + patcher = patch( + "pylabrobot.plate_reading.tecan.infinite_backend.USB", + return_value=self.mock_usb, + ) + self.mock_usb_class = patcher.start() + self.addCleanup(patcher.stop) + + self.backend = TecanInfinite200ProBackend( + scan_config=InfiniteScanConfig(counts_per_mm_x=1000, counts_per_mm_y=1000) + ) + self.plate = _make_test_plate() + self.plate.location = Coordinate.zero() + + def _frame(self, command: str) -> bytes: + """Helper to frame a command.""" + return TecanInfinite200ProBackend._frame_command(command) + + async def test_open(self): + self.backend._ready = True + self.backend._device_initialized = True + + await self.backend.open() + + self.mock_usb.write.assert_has_calls( + [ + call(self._frame("ABSOLUTE MTP,OUT")), + call(self._frame("BY#T5000")), + ] + ) + + async def test_close(self): + self.backend._ready = True + self.backend._device_initialized = True + + await self.backend.close(self.plate) + + self.mock_usb.write.assert_has_calls( + [ + call(self._frame("ABSOLUTE MTP,IN")), + call(self._frame("BY#T5000")), + ] + ) + + async def test_read_absorbance_commands(self): + """Test that read_absorbance sends the correct configuration commands.""" + self.backend._ready = True + self.backend._device_initialized = True + + async def mock_await(decoder, row_count, mode): + prep_marker, prep_blob = _abs_prepare_blob(6000, 0, 1000, 0, 1000) + decoder.feed_bin(prep_marker, prep_blob) + for _ in range(row_count): + data_marker, data_blob = _abs_data_blob(6000, 500, 1000) + decoder.feed_bin(data_marker, data_blob) + + with patch.object(self.backend, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + await self.backend.read_absorbance(self.plate, [], wavelength=600) + + self.mock_usb.write.assert_has_calls( + [ + # _begin_run + call(self._frame("KEYLOCK ON")), + # _configure_absorbance + call(self._frame("MODE ABS")), + call(self._frame("EXCITATION CLEAR")), + call(self._frame("TIME CLEAR")), + call(self._frame("GAIN CLEAR")), + call(self._frame("READS CLEAR")), + call(self._frame("POSITION CLEAR")), + call(self._frame("MIRROR CLEAR")), + call(self._frame("EXCITATION 0,ABS,6000,90,0")), + call(self._frame("EXCITATION 1,ABS,6000,90,0")), + call(self._frame("READS 0,NUMBER=25")), + call(self._frame("READS 1,NUMBER=25")), + call(self._frame("TIME 0,READDELAY=0")), + call(self._frame("TIME 1,READDELAY=0")), + call(self._frame("SCAN DIRECTION=ALTUP")), + call(self._frame("#RATIO LABELS")), + call(self._frame("BEAM DIAMETER=700")), + call(self._frame("RATIO LABELS=1")), + call(self._frame("PREPARE REF")), + # row scans (2 rows in test plate) + call(self._frame("ABSOLUTE MTP,Y=8000")), + call(self._frame("SCAN DIRECTION=ALTUP")), + call(self._frame("SCANX 3000,23000,3")), + call(self._frame("ABSOLUTE MTP,Y=16000")), + call(self._frame("SCAN DIRECTION=ALTUP")), + call(self._frame("SCANX 23000,3000,3")), + # _end_run + call(self._frame("TERMINATE")), + call(self._frame("CHECK MTP.STEPLOSS")), + call(self._frame("CHECK ABS.STEPLOSS")), + call(self._frame("KEYLOCK OFF")), + call(self._frame("ABSOLUTE MTP,IN")), + ] + ) + + async def test_read_fluorescence_commands(self): + """Test that read_fluorescence sends the correct configuration commands.""" + self.backend._ready = True + self.backend._device_initialized = True + + async def mock_await(decoder, row_count, mode): + prep_marker, prep_blob = _flr_prepare_blob(4850, 0, 0, 1000) + decoder.feed_bin(prep_marker, prep_blob) + for _ in range(row_count): + data_marker, data_blob = _flr_data_blob(4850, 5200, 500, 1000) + decoder.feed_bin(data_marker, data_blob) + + with patch.object(self.backend, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + await self.backend.read_fluorescence( + self.plate, [], excitation_wavelength=485, emission_wavelength=520, focal_height=20.0 + ) + + # Fluorescence config is sent twice (UI behavior) + fl_config_commands = [ + call(self._frame("MODE FI.TOP")), + call(self._frame("READS CLEAR")), + call(self._frame("EXCITATION CLEAR")), + call(self._frame("EMISSION CLEAR")), + call(self._frame("TIME CLEAR")), + call(self._frame("GAIN CLEAR")), + call(self._frame("POSITION CLEAR")), + call(self._frame("MIRROR CLEAR")), + call(self._frame("EXCITATION 0,FI,4850,50,0")), + call(self._frame("EMISSION 0,FI,5200,200,0")), + call(self._frame("TIME 0,INTEGRATION=20")), + call(self._frame("TIME 0,LAG=0")), + call(self._frame("TIME 0,READDELAY=0")), + call(self._frame("GAIN 0,VALUE=100")), + call(self._frame("POSITION 0,Z=20000")), + call(self._frame("BEAM DIAMETER=3000")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("RATIO LABELS=1")), + call(self._frame("READS 0,NUMBER=25")), + call(self._frame("EXCITATION 1,FI,4850,50,0")), + call(self._frame("EMISSION 1,FI,5200,200,0")), + call(self._frame("TIME 1,INTEGRATION=20")), + call(self._frame("TIME 1,LAG=0")), + call(self._frame("TIME 1,READDELAY=0")), + call(self._frame("GAIN 1,VALUE=100")), + call(self._frame("POSITION 1,Z=20000")), + call(self._frame("READS 1,NUMBER=25")), + ] + + self.mock_usb.write.assert_has_calls( + [ + # _begin_run + call(self._frame("KEYLOCK ON")), + # _configure_fluorescence (sent twice) + *fl_config_commands, + *fl_config_commands, + call(self._frame("PREPARE REF")), + # row scans (2 rows in test plate) + call(self._frame("ABSOLUTE MTP,Y=8000")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("SCANX 3000,23000,3")), + call(self._frame("ABSOLUTE MTP,Y=16000")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("SCANX 23000,3000,3")), + # _end_run + call(self._frame("TERMINATE")), + call(self._frame("CHECK MTP.STEPLOSS")), + call(self._frame("CHECK FI.TOP.STEPLOSS")), + call(self._frame("CHECK FI.STEPLOSS.Z")), + call(self._frame("KEYLOCK OFF")), + call(self._frame("ABSOLUTE MTP,IN")), + ] + ) + + async def test_read_luminescence_commands(self): + """Test that read_luminescence sends the correct configuration commands.""" + self.backend._ready = True + self.backend._device_initialized = True + + async def mock_await(decoder, row_count, mode): + prep_blob = bytes(14) + decoder.feed_bin(10, prep_blob) + for _ in range(row_count): + data_marker, data_blob = _lum_data_blob(0, 1000) + decoder.feed_bin(data_marker, data_blob) + + with patch.object(self.backend, "_await_measurements", side_effect=mock_await): + with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): + await self.backend.read_luminescence(self.plate, [], focal_height=20.0) + + self.mock_usb.write.assert_has_calls( + [ + # _begin_run + call(self._frame("KEYLOCK ON")), + # _configure_luminescence + call(self._frame("MODE LUM")), + call(self._frame("CHECK LUM.FIBER")), + call(self._frame("CHECK LUM.LID")), + call(self._frame("CHECK LUM.STEPLOSS")), + call(self._frame("MODE LUM")), + call(self._frame("READS CLEAR")), + call(self._frame("EMISSION CLEAR")), + call(self._frame("TIME CLEAR")), + call(self._frame("GAIN CLEAR")), + call(self._frame("POSITION CLEAR")), + call(self._frame("MIRROR CLEAR")), + call(self._frame("POSITION LUM,Z=14620")), + call(self._frame("TIME 0,INTEGRATION=3000000")), + call(self._frame("READS 0,NUMBER=25")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("RATIO LABELS=1")), + call(self._frame("EMISSION 1,EMPTY,0,0,0")), + call(self._frame("TIME 1,INTEGRATION=1000000")), + call(self._frame("TIME 1,READDELAY=0")), + call(self._frame("READS 1,NUMBER=25")), + call(self._frame("#EMISSION ATTENUATION")), + call(self._frame("PREPARE REF")), + # row scans (2 rows, non-serpentine so both scan left-to-right) + call(self._frame("ABSOLUTE MTP,Y=8000")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("SCANX 3000,23000,3")), + call(self._frame("ABSOLUTE MTP,Y=16000")), + call(self._frame("SCAN DIRECTION=UP")), + call(self._frame("SCANX 3000,23000,3")), + # _end_run + call(self._frame("TERMINATE")), + call(self._frame("CHECK MTP.STEPLOSS")), + call(self._frame("CHECK LUM.STEPLOSS")), + call(self._frame("KEYLOCK OFF")), + call(self._frame("ABSOLUTE MTP,IN")), + ] + ) From 9bff32c38c866d9893c52e7cd3dda2a26764b55c Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 11:13:52 -0800 Subject: [PATCH 18/33] Extract common scan loop into _run_scan helper Reduces duplication across read_absorbance, read_fluorescence, and read_luminescence methods. Also removes unused _active_mode field. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 166 ++++++++++-------- 1 file changed, 90 insertions(+), 76 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index ba1fb897e35..62d1aabb248 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -532,7 +532,6 @@ def __init__( self._parser = _StreamParser(allow_bare_ascii=True) self._run_active = False self._active_step_loss_commands: List[str] = [] - self._active_mode: Optional[str] = None self._packet_log_path = packet_log_path self._packet_log_handle: Optional[TextIO] = None self._packet_log_lock = asyncio.Lock() @@ -574,6 +573,58 @@ async def close(self, plate: Optional[Plate]) -> None: # noqa: ARG002 await self._send_command("ABSOLUTE MTP,IN") await self._send_command("BY#T5000") + async def _run_scan( + self, + plate: Plate, + wells: List[Well], + decoder: _MeasurementDecoder, + mode: str, + step_loss_commands: List[str], + serpentine: bool, + scan_direction: str, + ) -> List[Well]: + """Run the common scan loop for all measurement types. + + Args: + plate: The plate to scan. + wells: The wells to scan. + decoder: The decoder to use for parsing measurements. + mode: The mode name for logging (e.g., "Absorbance"). + step_loss_commands: Commands to run after the scan to check for step loss. + serpentine: Whether to use serpentine scan order. + scan_direction: The scan direction command (e.g., "ALTUP", "UP"). + + Returns: + The list of wells in scan order. + """ + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=serpentine) + + self._active_step_loss_commands = step_loss_commands + + for row_index, row_wells in self._group_by_row(ordered_wells): + start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=serpentine) + _, y_stage = self._map_well_to_stage(row_wells[0]) + + await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") + await self._send_command(f"SCAN DIRECTION={scan_direction}") + await self._send_command( + f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False + ) + logger.info( + "Queued %s scan row %s (%s wells): y=%s, x=%s..%s", + mode.lower(), + row_index, + count, + y_stage, + start_x, + end_x, + ) + await self._await_measurements(decoder, count, mode) + await self._await_scan_terminal(decoder.pop_terminal()) + + return scan_wells + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: """Queue and execute an absorbance scan.""" @@ -582,33 +633,20 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + decoder = _AbsorbanceRunDecoder(len(scan_wells)) - self._active_step_loss_commands = ["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"] - self._active_mode = "ABS" await self._begin_run() try: - decoder = _AbsorbanceRunDecoder(len(scan_wells)) await self._configure_absorbance(wavelength) - - for row_index, row_wells in self._group_by_row(ordered_wells): - start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) - _, y_stage = self._map_well_to_stage(row_wells[0]) - - await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_command("SCAN DIRECTION=ALTUP") - await self._send_command( - f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False - ) - logger.info( - "Queued scan row %s (%s wells): y=%s, x=%s..%s", - row_index, - count, - y_stage, - start_x, - end_x, - ) - await self._await_measurements(decoder, count, "Absorbance") - await self._await_scan_terminal(decoder.pop_terminal()) + scan_wells = await self._run_scan( + plate=plate, + wells=wells, + decoder=decoder, + mode="Absorbance", + step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"], + serpentine=True, + scan_direction="ALTUP", + ) if len(decoder.measurements) != len(scan_wells): raise RuntimeError("Absorbance decoder did not complete scan.") @@ -684,44 +722,33 @@ async def read_fluorescence( if focal_height < 0: raise ValueError("Focal height must be non-negative for fluorescence scans.") - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) - self._active_step_loss_commands = [ - "CHECK MTP.STEPLOSS", - "CHECK FI.TOP.STEPLOSS", - "CHECK FI.STEPLOSS.Z", - ] - self._active_mode = "FI.TOP" await self._begin_run() try: await self._configure_fluorescence(excitation_wavelength, emission_wavelength) if self._current_fluorescence_excitation is None: raise RuntimeError("Fluorescence configuration missing excitation wavelength.") + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) decoder = _FluorescenceRunDecoder( len(scan_wells), self._current_fluorescence_excitation, self._current_fluorescence_emission, ) - for row_index, row_wells in self._group_by_row(ordered_wells): - start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=True) - _, y_stage = self._map_well_to_stage(row_wells[0]) - - await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_command("SCAN DIRECTION=UP") - await self._send_command( - f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False - ) - logger.info( - "Queued fluorescence scan row %s (%s wells): y=%s, x=%s..%s", - row_index, - count, - y_stage, - start_x, - end_x, - ) - await self._await_measurements(decoder, count, "Fluorescence") - await self._await_scan_terminal(decoder.pop_terminal()) + scan_wells = await self._run_scan( + plate=plate, + wells=wells, + decoder=decoder, + mode="Fluorescence", + step_loss_commands=[ + "CHECK MTP.STEPLOSS", + "CHECK FI.TOP.STEPLOSS", + "CHECK FI.STEPLOSS.Z", + ], + serpentine=True, + scan_direction="UP", + ) if len(decoder.intensities) != len(scan_wells): raise RuntimeError("Fluorescence decoder did not complete scan.") @@ -792,40 +819,29 @@ async def read_luminescence( if focal_height < 0: raise ValueError("Focal height must be non-negative for luminescence scans.") - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) - self._active_step_loss_commands = ["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"] - self._active_mode = "LUM" await self._begin_run() try: await self._configure_luminescence() dark_t = self._lum_integration_s.get(0, 0.0) meas_t = self._lum_integration_s.get(1, 0.0) + + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) decoder = _LuminescenceRunDecoder( len(scan_wells), dark_integration_s=dark_t, meas_integration_s=meas_t, ) - for row_index, row_wells in self._group_by_row(ordered_wells): - start_x, end_x, count = self._scan_range(row_index, row_wells, serpentine=False) - _, y_stage = self._map_well_to_stage(row_wells[0]) - - await self._send_command(f"ABSOLUTE MTP,Y={y_stage}") - await self._send_command("SCAN DIRECTION=UP") - await self._send_command( - f"SCANX {start_x},{end_x},{count}", wait_for_terminal=False, read_response=False - ) - logger.info( - "Queued luminescence scan row %s (%s wells): y=%s, x=%s..%s", - row_index, - count, - y_stage, - start_x, - end_x, - ) - await self._await_measurements(decoder, count, "Luminescence") - await self._await_scan_terminal(decoder.pop_terminal()) + scan_wells = await self._run_scan( + plate=plate, + wells=wells, + decoder=decoder, + mode="Luminescence", + step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"], + serpentine=False, + scan_direction="UP", + ) if len(decoder.measurements) != len(scan_wells): raise RuntimeError("Luminescence decoder did not complete scan.") @@ -1023,7 +1039,6 @@ async def _end_run(self) -> None: finally: self._run_active = False self._active_step_loss_commands = [] - self._active_mode = None async def _cleanup_protocol(self) -> None: async def send_cleanup_cmd(cmd: str) -> None: @@ -1040,7 +1055,6 @@ async def send_cleanup_cmd(cmd: str) -> None: await send_cleanup_cmd("ABSOLUTE MTP,IN") self._run_active = False self._active_step_loss_commands = [] - self._active_mode = None async def _query_mode_capabilities(self, mode: str) -> None: commands = self._MODE_CAPABILITY_COMMANDS.get(mode) From 1e89fce1ec10558d6249345f9739b322ea56e39e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 11:19:13 -0800 Subject: [PATCH 19/33] Remove duplicate well computation in _run_scan Callers now compute ordered_wells and scan_wells once and pass ordered_wells to _run_scan directly. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 62d1aabb248..59574970f24 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -575,31 +575,23 @@ async def close(self, plate: Optional[Plate]) -> None: # noqa: ARG002 async def _run_scan( self, - plate: Plate, - wells: List[Well], + ordered_wells: Sequence[Well], decoder: _MeasurementDecoder, mode: str, step_loss_commands: List[str], serpentine: bool, scan_direction: str, - ) -> List[Well]: + ) -> None: """Run the common scan loop for all measurement types. Args: - plate: The plate to scan. - wells: The wells to scan. + ordered_wells: The wells to scan in row-major order. decoder: The decoder to use for parsing measurements. mode: The mode name for logging (e.g., "Absorbance"). step_loss_commands: Commands to run after the scan to check for step loss. serpentine: Whether to use serpentine scan order. scan_direction: The scan direction command (e.g., "ALTUP", "UP"). - - Returns: - The list of wells in scan order. """ - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=serpentine) - self._active_step_loss_commands = step_loss_commands for row_index, row_wells in self._group_by_row(ordered_wells): @@ -623,8 +615,6 @@ async def _run_scan( await self._await_measurements(decoder, count, mode) await self._await_scan_terminal(decoder.pop_terminal()) - return scan_wells - async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: """Queue and execute an absorbance scan.""" @@ -638,9 +628,8 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int await self._begin_run() try: await self._configure_absorbance(wavelength) - scan_wells = await self._run_scan( - plate=plate, - wells=wells, + await self._run_scan( + ordered_wells=ordered_wells, decoder=decoder, mode="Absorbance", step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK ABS.STEPLOSS"], @@ -722,23 +711,23 @@ async def read_fluorescence( if focal_height < 0: raise ValueError("Focal height must be non-negative for fluorescence scans.") + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) + await self._begin_run() try: await self._configure_fluorescence(excitation_wavelength, emission_wavelength) if self._current_fluorescence_excitation is None: raise RuntimeError("Fluorescence configuration missing excitation wavelength.") - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=True) decoder = _FluorescenceRunDecoder( len(scan_wells), self._current_fluorescence_excitation, self._current_fluorescence_emission, ) - scan_wells = await self._run_scan( - plate=plate, - wells=wells, + await self._run_scan( + ordered_wells=ordered_wells, decoder=decoder, mode="Fluorescence", step_loss_commands=[ @@ -819,23 +808,23 @@ async def read_luminescence( if focal_height < 0: raise ValueError("Focal height must be non-negative for luminescence scans.") + ordered_wells = wells if wells else plate.get_all_items() + scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) + await self._begin_run() try: await self._configure_luminescence() dark_t = self._lum_integration_s.get(0, 0.0) meas_t = self._lum_integration_s.get(1, 0.0) - ordered_wells = wells if wells else plate.get_all_items() - scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) decoder = _LuminescenceRunDecoder( len(scan_wells), dark_integration_s=dark_t, meas_integration_s=meas_t, ) - scan_wells = await self._run_scan( - plate=plate, - wells=wells, + await self._run_scan( + ordered_wells=ordered_wells, decoder=decoder, mode="Luminescence", step_loss_commands=["CHECK MTP.STEPLOSS", "CHECK LUM.STEPLOSS"], From 31861e3e22cc67d27402a681c0a81bca38263444 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 12:40:01 -0800 Subject: [PATCH 20/33] remove unused state from configuration --- .../plate_reading/tecan/infinite_backend.py | 44 +++++-------------- .../tecan/infinite_backend_tests.py | 2 +- 2 files changed, 12 insertions(+), 34 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 59574970f24..90d1a176bcf 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -525,9 +525,6 @@ def __init__( self._max_read_iterations = 200 self._device_initialized = False self._mode_capabilities: Dict[str, Dict[str, str]] = {} - self._current_fluorescence_excitation: Optional[int] = None - self._current_fluorescence_emission: Optional[int] = None - self._lum_integration_s: Dict[int, float] = {} self._pending_bin_events: List[Tuple[int, bytes]] = [] self._parser = _StreamParser(allow_bare_ascii=True) self._run_active = False @@ -717,14 +714,7 @@ async def read_fluorescence( await self._begin_run() try: await self._configure_fluorescence(excitation_wavelength, emission_wavelength) - if self._current_fluorescence_excitation is None: - raise RuntimeError("Fluorescence configuration missing excitation wavelength.") - - decoder = _FluorescenceRunDecoder( - len(scan_wells), - self._current_fluorescence_excitation, - self._current_fluorescence_emission, - ) + decoder = _FluorescenceRunDecoder(len(scan_wells)) await self._run_scan( ordered_wells=ordered_wells, @@ -758,8 +748,6 @@ async def read_fluorescence( async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> None: ex_decitenth = int(round(excitation_nm * 10)) em_decitenth = int(round(emission_nm * 10)) - self._current_fluorescence_excitation = ex_decitenth - self._current_fluorescence_emission = em_decitenth reads_number = max(1, int(self.config.flashes)) beam_diameter = self._capability_numeric("FI.TOP", "#BEAM DIAMETER", 3000) @@ -811,16 +799,17 @@ async def read_luminescence( ordered_wells = wells if wells else plate.get_all_items() scan_wells = self._scan_visit_order(ordered_wells, serpentine=False) + dark_integration = 3_000_000 + meas_integration = 1_000_000 + await self._begin_run() try: - await self._configure_luminescence() - dark_t = self._lum_integration_s.get(0, 0.0) - meas_t = self._lum_integration_s.get(1, 0.0) + await self._configure_luminescence(dark_integration, meas_integration) decoder = _LuminescenceRunDecoder( len(scan_wells), - dark_integration_s=dark_t, - meas_integration_s=meas_t, + dark_integration_s=_integration_value_to_seconds(dark_integration), + meas_integration_s=_integration_value_to_seconds(meas_integration), ) await self._run_scan( @@ -869,7 +858,7 @@ async def _await_scan_terminal(self, saw_terminal: bool) -> None: return await self._read_command_response() - async def _configure_luminescence(self) -> None: + async def _configure_luminescence(self, dark_integration: int, meas_integration: int) -> None: await self._send_command("MODE LUM") # Pre-flight safety checks observed in captures (queries omitted). await self._send_command("CHECK LUM.FIBER") @@ -877,10 +866,6 @@ async def _configure_luminescence(self) -> None: await self._send_command("CHECK LUM.STEPLOSS") await self._send_command("MODE LUM") reads_number = max(1, int(self.config.flashes)) - self._lum_integration_s = { - 0: _integration_value_to_seconds(3_000_000), - 1: _integration_value_to_seconds(1_000_000), - } await self._send_command("READS CLEAR", allow_timeout=True) await self._send_command("EMISSION CLEAR", allow_timeout=True) await self._send_command("TIME CLEAR", allow_timeout=True) @@ -888,12 +873,12 @@ async def _configure_luminescence(self) -> None: await self._send_command("POSITION CLEAR", allow_timeout=True) await self._send_command("MIRROR CLEAR", allow_timeout=True) await self._send_command("POSITION LUM,Z=14620", allow_timeout=True) - await self._send_command("TIME 0,INTEGRATION=3000000", allow_timeout=True) + await self._send_command(f"TIME 0,INTEGRATION={dark_integration}", allow_timeout=True) await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) await self._send_command("RATIO LABELS=1", allow_timeout=True) await self._send_command("EMISSION 1,EMPTY,0,0,0", allow_timeout=True) - await self._send_command("TIME 1,INTEGRATION=1000000", allow_timeout=True) + await self._send_command(f"TIME 1,INTEGRATION={meas_integration}", allow_timeout=True) await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) await self._send_command("#EMISSION ATTENUATION", allow_timeout=True) @@ -1217,15 +1202,8 @@ class _FluorescenceRunDecoder(_MeasurementDecoder): STATUS_FRAME_LEN = 31 - def __init__( - self, - expected_wells: int, - excitation_decitenth: int, - emission_decitenth: Optional[int], - ) -> None: + def __init__(self, expected_wells: int) -> None: super().__init__(expected_wells) - self._excitation = excitation_decitenth - self._emission = emission_decitenth self._intensities: List[int] = [] self._prepare: Optional[_FluorescencePrepare] = None diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 9ed9b427f29..7acf67e26aa 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -563,7 +563,7 @@ def extract_actual(decoder): def test_decode_fluorescence_pattern(self): excitation = 485 emission = 520 - decoder = _FluorescenceRunDecoder(len(self.scan_wells), excitation * 10, emission * 10) + decoder = _FluorescenceRunDecoder(len(self.scan_wells)) prep_marker, prep_blob = _flr_prepare_blob( excitation * 10, meas_dark=0, ref_dark=0, ref_bright=1000 ) From 98c649054921925edf70ce3ba05e6a683ad4dfe7 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 12:45:00 -0800 Subject: [PATCH 21/33] Extract common CLEAR commands into _clear_mode_settings helper Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 37 ++++++++----------- .../tecan/infinite_backend_tests.py | 4 +- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 90d1a176bcf..9e37c20f85e 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -656,18 +656,25 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int finally: await self._end_run() + async def _clear_mode_settings(self, excitation: bool = False, emission: bool = False) -> None: + """Clear mode settings before configuring a new scan.""" + if excitation: + await self._send_command("EXCITATION CLEAR", allow_timeout=True) + if emission: + await self._send_command("EMISSION CLEAR", allow_timeout=True) + await self._send_command("TIME CLEAR", allow_timeout=True) + await self._send_command("GAIN CLEAR", allow_timeout=True) + await self._send_command("READS CLEAR", allow_timeout=True) + await self._send_command("POSITION CLEAR", allow_timeout=True) + await self._send_command("MIRROR CLEAR", allow_timeout=True) + async def _configure_absorbance(self, wavelength_nm: int) -> None: wl_decitenth = int(round(wavelength_nm * 10)) bw_decitenth = int(round(self._auto_bandwidth(wavelength_nm) * 10)) reads_number = max(1, int(self.config.flashes)) await self._send_command("MODE ABS") - await self._send_command("EXCITATION CLEAR", allow_timeout=True) - await self._send_command("TIME CLEAR", allow_timeout=True) - await self._send_command("GAIN CLEAR", allow_timeout=True) - await self._send_command("READS CLEAR", allow_timeout=True) - await self._send_command("POSITION CLEAR", allow_timeout=True) - await self._send_command("MIRROR CLEAR", allow_timeout=True) + await self._clear_mode_settings(excitation=True) await self._send_command( f"EXCITATION 0,ABS,{wl_decitenth},{bw_decitenth},0", allow_timeout=True ) @@ -753,17 +760,8 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> # UI issues the entire FI configuration twice before PREPARE REF. for _ in range(2): - # clear commands await self._send_command("MODE FI.TOP", allow_timeout=True) - await self._send_command("READS CLEAR", allow_timeout=True) - await self._send_command("EXCITATION CLEAR", allow_timeout=True) - await self._send_command("EMISSION CLEAR", allow_timeout=True) - await self._send_command("TIME CLEAR", allow_timeout=True) - await self._send_command("GAIN CLEAR", allow_timeout=True) - await self._send_command("POSITION CLEAR", allow_timeout=True) - await self._send_command("MIRROR CLEAR", allow_timeout=True) - - # configure commands + await self._clear_mode_settings(excitation=True, emission=True) await self._send_command(f"EXCITATION 0,FI,{ex_decitenth},50,0", allow_timeout=True) await self._send_command(f"EMISSION 0,FI,{em_decitenth},200,0", allow_timeout=True) await self._send_command("TIME 0,INTEGRATION=20", allow_timeout=True) @@ -866,12 +864,7 @@ async def _configure_luminescence(self, dark_integration: int, meas_integration: await self._send_command("CHECK LUM.STEPLOSS") await self._send_command("MODE LUM") reads_number = max(1, int(self.config.flashes)) - await self._send_command("READS CLEAR", allow_timeout=True) - await self._send_command("EMISSION CLEAR", allow_timeout=True) - await self._send_command("TIME CLEAR", allow_timeout=True) - await self._send_command("GAIN CLEAR", allow_timeout=True) - await self._send_command("POSITION CLEAR", allow_timeout=True) - await self._send_command("MIRROR CLEAR", allow_timeout=True) + await self._clear_mode_settings(emission=True) await self._send_command("POSITION LUM,Z=14620", allow_timeout=True) await self._send_command(f"TIME 0,INTEGRATION={dark_integration}", allow_timeout=True) await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 7acf67e26aa..234b93551e6 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -774,11 +774,11 @@ async def mock_await(decoder, row_count, mode): # Fluorescence config is sent twice (UI behavior) fl_config_commands = [ call(self._frame("MODE FI.TOP")), - call(self._frame("READS CLEAR")), call(self._frame("EXCITATION CLEAR")), call(self._frame("EMISSION CLEAR")), call(self._frame("TIME CLEAR")), call(self._frame("GAIN CLEAR")), + call(self._frame("READS CLEAR")), call(self._frame("POSITION CLEAR")), call(self._frame("MIRROR CLEAR")), call(self._frame("EXCITATION 0,FI,4850,50,0")), @@ -853,10 +853,10 @@ async def mock_await(decoder, row_count, mode): call(self._frame("CHECK LUM.LID")), call(self._frame("CHECK LUM.STEPLOSS")), call(self._frame("MODE LUM")), - call(self._frame("READS CLEAR")), call(self._frame("EMISSION CLEAR")), call(self._frame("TIME CLEAR")), call(self._frame("GAIN CLEAR")), + call(self._frame("READS CLEAR")), call(self._frame("POSITION CLEAR")), call(self._frame("MIRROR CLEAR")), call(self._frame("POSITION LUM,Z=14620")), From 793a7c8694868ab13b108283751570e19223887d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 13:11:17 -0800 Subject: [PATCH 22/33] Rename Prepare to Calibration in infinite backend The "Prepare" naming came from the device command "PREPARE REF" but didn't convey what the data actually represents. These structures contain calibration data (dark/bright references, gain values) used to convert raw measurements into calibrated values. Renamed: - _AbsorbancePrepare -> _AbsorbanceCalibration - _FluorescencePrepare -> _FluorescenceCalibration - _LuminescencePrepare -> _LuminescenceCalibration - Related functions, properties, and variables Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 104 +++++++++--------- .../tecan/infinite_backend_tests.py | 32 +++--- 2 files changed, 68 insertions(+), 68 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 9e37c20f85e..cb21d91c72f 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -40,7 +40,7 @@ def _integration_value_to_seconds(value: int) -> float: return value / 1_000_000.0 if value >= 1000 else value / 1000.0 -def _is_abs_prepare_marker(marker: int) -> bool: +def _is_abs_calibration_marker(marker: int) -> bool: return marker >= 22 and (marker - 4) % 18 == 0 @@ -57,7 +57,7 @@ def _split_payload_and_trailer(marker: int, blob: bytes) -> Optional[Tuple[bytes @dataclass(frozen=True) -class _AbsorbancePrepareItem: +class _AbsorbanceCalibrationItem: ticker_overflows: int ticker_counter: int meas_gain: int @@ -69,12 +69,12 @@ class _AbsorbancePrepareItem: @dataclass(frozen=True) -class _AbsorbancePrepare: +class _AbsorbanceCalibration: ex: int - items: List[_AbsorbancePrepareItem] + items: List[_AbsorbanceCalibrationItem] -def _decode_abs_prepare(marker: int, blob: bytes) -> Optional[_AbsorbancePrepare]: +def _decode_abs_calibration(marker: int, blob: bytes) -> Optional[_AbsorbanceCalibration]: split = _split_payload_and_trailer(marker, blob) if split is None: return None @@ -86,10 +86,10 @@ def _decode_abs_prepare(marker: int, blob: bytes) -> Optional[_AbsorbancePrepare reader = Reader(payload, little_endian=False) reader.raw_bytes(2) # skip first 2 bytes ex = reader.u16() - items: List[_AbsorbancePrepareItem] = [] + items: List[_AbsorbanceCalibrationItem] = [] while reader.has_remaining(): items.append( - _AbsorbancePrepareItem( + _AbsorbanceCalibrationItem( ticker_overflows=reader.u32(), ticker_counter=reader.u16(), meas_gain=reader.u16(), @@ -100,7 +100,7 @@ def _decode_abs_prepare(marker: int, blob: bytes) -> Optional[_AbsorbancePrepare ref_bright=reader.u16(), ) ) - return _AbsorbancePrepare(ex=ex, items=items) + return _AbsorbanceCalibration(ex=ex, items=items) def _decode_abs_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: @@ -125,30 +125,30 @@ def _decode_abs_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[ def _absorbance_od_calibrated( - prep: _AbsorbancePrepare, meas_ref_items: List[Tuple[int, int]], od_max: float = 4.0 + cal: _AbsorbanceCalibration, meas_ref_items: List[Tuple[int, int]], od_max: float = 4.0 ) -> float: - if not prep.items: - raise ValueError("ABS prepare packet contained no calibration items.") + if not cal.items: + raise ValueError("ABS calibration packet contained no calibration items.") min_corr_trans = math.pow(10.0, -od_max) - if len(prep.items) == len(meas_ref_items) and len(prep.items) > 1: + if len(cal.items) == len(meas_ref_items) and len(cal.items) > 1: corr_trans_vals: List[float] = [] - for (meas, ref), cal in zip(meas_ref_items, prep.items): - denom_corr = cal.meas_bright - cal.meas_dark + for (meas, ref), cal_item in zip(meas_ref_items, cal.items): + denom_corr = cal_item.meas_bright - cal_item.meas_dark if denom_corr == 0: continue - f_corr = (cal.ref_bright - cal.ref_dark) / denom_corr - denom = ref - cal.ref_dark + f_corr = (cal_item.ref_bright - cal_item.ref_dark) / denom_corr + denom = ref - cal_item.ref_dark if denom == 0: continue - corr_trans_vals.append(((meas - cal.meas_dark) / denom) * f_corr) + corr_trans_vals.append(((meas - cal_item.meas_dark) / denom) * f_corr) if not corr_trans_vals: raise ZeroDivisionError("ABS invalid: no usable reads after per-read calibration.") corr_trans = max(sum(corr_trans_vals) / len(corr_trans_vals), min_corr_trans) return float(-math.log10(corr_trans)) - cal0 = prep.items[0] + cal0 = cal.items[0] denom_corr = cal0.meas_bright - cal0.meas_dark if denom_corr == 0: raise ZeroDivisionError("ABS calibration invalid: meas_bright == meas_dark") @@ -169,14 +169,14 @@ def _absorbance_od_calibrated( @dataclass(frozen=True) -class _FluorescencePrepare: +class _FluorescenceCalibration: ex: int meas_dark: int ref_dark: int ref_bright: int -def _decode_flr_prepare(marker: int, blob: bytes) -> Optional[_FluorescencePrepare]: +def _decode_flr_calibration(marker: int, blob: bytes) -> Optional[_FluorescenceCalibration]: split = _split_payload_and_trailer(marker, blob) if split is None: return None @@ -190,7 +190,7 @@ def _decode_flr_prepare(marker: int, blob: bytes) -> Optional[_FluorescencePrepa reader.raw_bytes(2) # skip bytes 12-13 ref_dark = reader.u16() ref_bright = reader.u16() - return _FluorescencePrepare( + return _FluorescenceCalibration( ex=ex, meas_dark=meas_dark, ref_dark=ref_dark, @@ -223,25 +223,25 @@ def _decode_flr_data( def _fluorescence_corrected( - prep: _FluorescencePrepare, meas_ref_items: List[Tuple[int, int]] + cal: _FluorescenceCalibration, meas_ref_items: List[Tuple[int, int]] ) -> int: if not meas_ref_items: return 0 meas_mean = sum(m for m, _ in meas_ref_items) / len(meas_ref_items) ref_mean = sum(r for _, r in meas_ref_items) / len(meas_ref_items) - denom = ref_mean - prep.ref_dark + denom = ref_mean - cal.ref_dark if denom == 0: return 0 - corr = (meas_mean - prep.meas_dark) * (prep.ref_bright - prep.ref_dark) / denom + corr = (meas_mean - cal.meas_dark) * (cal.ref_bright - cal.ref_dark) / denom return int(round(corr)) @dataclass(frozen=True) -class _LuminescencePrepare: +class _LuminescenceCalibration: ref_dark: int -def _decode_lum_prepare(marker: int, blob: bytes) -> Optional[_LuminescencePrepare]: +def _decode_lum_calibration(marker: int, blob: bytes) -> Optional[_LuminescenceCalibration]: split = _split_payload_and_trailer(marker, blob) if split is None: return None @@ -250,7 +250,7 @@ def _decode_lum_prepare(marker: int, blob: bytes) -> Optional[_LuminescencePrepa return None reader = Reader(payload, little_endian=False) reader.raw_bytes(6) # skip bytes 0-5 - return _LuminescencePrepare(ref_dark=reader.i32()) + return _LuminescenceCalibration(ref_dark=reader.i32()) def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: @@ -273,7 +273,7 @@ def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[ def _luminescence_intensity( - prep: _LuminescencePrepare, + cal: _LuminescenceCalibration, counts: List[int], dark_integration_s: float, meas_integration_s: float, @@ -283,7 +283,7 @@ def _luminescence_intensity( if dark_integration_s == 0 or meas_integration_s == 0: return 0 count_mean = sum(counts) / len(counts) - corrected_rate = (count_mean / meas_integration_s) - (prep.ref_dark / dark_integration_s) + corrected_rate = (count_mean / meas_integration_s) - (cal.ref_dark / dark_integration_s) return int(corrected_rate) @@ -637,12 +637,12 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int if len(decoder.measurements) != len(scan_wells): raise RuntimeError("Absorbance decoder did not complete scan.") intensities: List[float] = [] - prep = decoder.prepare - if prep is None: - raise RuntimeError("ABS prepare packet not seen; cannot compute calibrated OD.") + cal = decoder.calibration + if cal is None: + raise RuntimeError("ABS calibration packet not seen; cannot compute calibrated OD.") for meas in decoder.measurements: items = meas.items or [(meas.sample, meas.reference)] - od = _absorbance_od_calibrated(prep, items) + od = _absorbance_od_calibrated(cal, items) intensities.append(od) matrix = self._format_plate_result(plate, scan_wells, intensities) return [ @@ -1157,27 +1157,27 @@ class _AbsorbanceRunDecoder(_MeasurementDecoder): def __init__(self, expected: int) -> None: super().__init__(expected) self.measurements: List[_AbsorbanceMeasurement] = [] - self._prepare: Optional[_AbsorbancePrepare] = None + self._calibration: Optional[_AbsorbanceCalibration] = None @property def count(self) -> int: return len(self.measurements) @property - def prepare(self) -> Optional[_AbsorbancePrepare]: - """Return the absorbance prepare data, if available.""" - return self._prepare + def calibration(self) -> Optional[_AbsorbanceCalibration]: + """Return the absorbance calibration data, if available.""" + return self._calibration def _should_consume_bin(self, marker: int) -> bool: - return _is_abs_prepare_marker(marker) or _is_abs_data_marker(marker) + return _is_abs_calibration_marker(marker) or _is_abs_data_marker(marker) def _handle_bin(self, marker: int, blob: bytes) -> None: - if _is_abs_prepare_marker(marker): - if self._prepare is not None: + if _is_abs_calibration_marker(marker): + if self._calibration is not None: return - decoded = _decode_abs_prepare(marker, blob) + decoded = _decode_abs_calibration(marker, blob) if decoded is not None: - self._prepare = decoded + self._calibration = decoded return if _is_abs_data_marker(marker): decoded = _decode_abs_data(marker, blob) @@ -1198,7 +1198,7 @@ class _FluorescenceRunDecoder(_MeasurementDecoder): def __init__(self, expected_wells: int) -> None: super().__init__(expected_wells) self._intensities: List[int] = [] - self._prepare: Optional[_FluorescencePrepare] = None + self._calibration: Optional[_FluorescenceCalibration] = None @property def count(self) -> int: @@ -1218,16 +1218,16 @@ def _should_consume_bin(self, marker: int) -> bool: def _handle_bin(self, marker: int, blob: bytes) -> None: if marker == 18: - decoded = _decode_flr_prepare(marker, blob) + decoded = _decode_flr_calibration(marker, blob) if decoded is not None: - self._prepare = decoded + self._calibration = decoded return decoded = _decode_flr_data(marker, blob) if decoded is None: return _label, _ex, _em, items = decoded - if self._prepare is not None: - intensity = _fluorescence_corrected(self._prepare, items) + if self._calibration is not None: + intensity = _fluorescence_corrected(self._calibration, items) else: if not items: intensity = 0 @@ -1253,7 +1253,7 @@ def __init__( ) -> None: super().__init__(expected) self.measurements: List[_LuminescenceMeasurement] = [] - self._prepare: Optional[_LuminescencePrepare] = None + self._calibration: Optional[_LuminescenceCalibration] = None self._dark_integration_s = float(dark_integration_s) self._meas_integration_s = float(meas_integration_s) @@ -1270,17 +1270,17 @@ def _should_consume_bin(self, marker: int) -> bool: def _handle_bin(self, marker: int, blob: bytes) -> None: if marker == 10: - decoded = _decode_lum_prepare(marker, blob) + decoded = _decode_lum_calibration(marker, blob) if decoded is not None: - self._prepare = decoded + self._calibration = decoded return decoded = _decode_lum_data(marker, blob) if decoded is None: return _label, _em, counts = decoded - if self._prepare is not None and self._dark_integration_s and self._meas_integration_s: + if self._calibration is not None and self._dark_integration_s and self._meas_integration_s: intensity = _luminescence_intensity( - self._prepare, counts, self._dark_integration_s, self._meas_integration_s + self._calibration, counts, self._dark_integration_s, self._meas_integration_s ) else: intensity = int(round(sum(counts) / len(counts))) if counts else 0 diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 234b93551e6..451792e8e4c 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -25,7 +25,7 @@ def _bin_blob(payload): return marker, payload + trailer -def _abs_prepare_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): +def _abs_calibration_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): header = _pack_u16([0, ex_decitenth]) item = (0).to_bytes(4, "big") + _pack_u16([0, 0, meas_dark, meas_bright, 0, ref_dark, ref_bright]) return _bin_blob(header + item) @@ -36,7 +36,7 @@ def _abs_data_blob(ex_decitenth, meas, ref): return _bin_blob(payload) -def _flr_prepare_blob(ex_decitenth, meas_dark, ref_dark, ref_bright): +def _flr_calibration_blob(ex_decitenth, meas_dark, ref_dark, ref_bright): words = [ex_decitenth, 0, 0, 0, 0, meas_dark, 0, ref_dark, ref_bright] return _bin_blob(_pack_u16(words)) @@ -532,16 +532,16 @@ def test_decode_absorbance_pattern(self): reference = 10000 max_absorbance = 1.0 decoder = _AbsorbanceRunDecoder(len(self.scan_wells)) - prep_marker, prep_blob = _abs_prepare_blob( + cal_marker, cal_blob = _abs_calibration_blob( wavelength * 10, meas_dark=0, meas_bright=1000, ref_dark=0, ref_bright=1000, ) - decoder.feed_bin(prep_marker, prep_blob) - prep = decoder.prepare - self.assertIsNotNone(prep) + decoder.feed_bin(cal_marker, cal_blob) + cal = decoder.calibration + self.assertIsNotNone(cal) def build_packet(intensity): target = 0.0 @@ -549,12 +549,12 @@ def build_packet(intensity): target = (intensity / self.max_intensity) * max_absorbance sample = max(1, int(round(reference / (10**target)))) marker, blob = _abs_data_blob(wavelength * 10, sample, reference) - expected = _absorbance_od_calibrated(prep, [(sample, reference)]) + expected = _absorbance_od_calibrated(cal, [(sample, reference)]) return marker, blob, expected def extract_actual(decoder): return [ - _absorbance_od_calibrated(prep, [(meas.sample, meas.reference)]) + _absorbance_od_calibrated(cal, [(meas.sample, meas.reference)]) for meas in decoder.measurements ] @@ -564,10 +564,10 @@ def test_decode_fluorescence_pattern(self): excitation = 485 emission = 520 decoder = _FluorescenceRunDecoder(len(self.scan_wells)) - prep_marker, prep_blob = _flr_prepare_blob( + cal_marker, cal_blob = _flr_calibration_blob( excitation * 10, meas_dark=0, ref_dark=0, ref_bright=1000 ) - decoder.feed_bin(prep_marker, prep_blob) + decoder.feed_bin(cal_marker, cal_blob) def build_packet(intensity): marker, blob = _flr_data_blob(excitation * 10, emission * 10, intensity, 1000) @@ -704,8 +704,8 @@ async def test_read_absorbance_commands(self): self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): - prep_marker, prep_blob = _abs_prepare_blob(6000, 0, 1000, 0, 1000) - decoder.feed_bin(prep_marker, prep_blob) + cal_marker, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) + decoder.feed_bin(cal_marker, cal_blob) for _ in range(row_count): data_marker, data_blob = _abs_data_blob(6000, 500, 1000) decoder.feed_bin(data_marker, data_blob) @@ -759,8 +759,8 @@ async def test_read_fluorescence_commands(self): self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): - prep_marker, prep_blob = _flr_prepare_blob(4850, 0, 0, 1000) - decoder.feed_bin(prep_marker, prep_blob) + cal_marker, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) + decoder.feed_bin(cal_marker, cal_blob) for _ in range(row_count): data_marker, data_blob = _flr_data_blob(4850, 5200, 500, 1000) decoder.feed_bin(data_marker, data_blob) @@ -833,8 +833,8 @@ async def test_read_luminescence_commands(self): self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): - prep_blob = bytes(14) - decoder.feed_bin(10, prep_blob) + cal_blob = bytes(14) + decoder.feed_bin(10, cal_blob) for _ in range(row_count): data_marker, data_blob = _lum_data_blob(0, 1000) decoder.feed_bin(data_marker, data_blob) From 4251a5eab0c3900555ba1c99f4c6ca4a24e142f8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 13:13:07 -0800 Subject: [PATCH 23/33] Fix type checking errors in decoder _handle_bin methods Use distinct variable names (cal/data) instead of reusing 'decoded' for both calibration and measurement data decoding to avoid mypy type conflicts. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index cb21d91c72f..50c35a60cff 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -1175,15 +1175,15 @@ def _handle_bin(self, marker: int, blob: bytes) -> None: if _is_abs_calibration_marker(marker): if self._calibration is not None: return - decoded = _decode_abs_calibration(marker, blob) - if decoded is not None: - self._calibration = decoded + cal = _decode_abs_calibration(marker, blob) + if cal is not None: + self._calibration = cal return if _is_abs_data_marker(marker): - decoded = _decode_abs_data(marker, blob) - if decoded is None: + data = _decode_abs_data(marker, blob) + if data is None: return - _label, _ex, items = decoded + _label, _ex, items = data sample, reference = items[0] if items else (0, 0) self.measurements.append( _AbsorbanceMeasurement(sample=sample, reference=reference, items=items) @@ -1218,14 +1218,14 @@ def _should_consume_bin(self, marker: int) -> bool: def _handle_bin(self, marker: int, blob: bytes) -> None: if marker == 18: - decoded = _decode_flr_calibration(marker, blob) - if decoded is not None: - self._calibration = decoded + cal = _decode_flr_calibration(marker, blob) + if cal is not None: + self._calibration = cal return - decoded = _decode_flr_data(marker, blob) - if decoded is None: + data = _decode_flr_data(marker, blob) + if data is None: return - _label, _ex, _em, items = decoded + _label, _ex, _em, items = data if self._calibration is not None: intensity = _fluorescence_corrected(self._calibration, items) else: @@ -1270,14 +1270,14 @@ def _should_consume_bin(self, marker: int) -> bool: def _handle_bin(self, marker: int, blob: bytes) -> None: if marker == 10: - decoded = _decode_lum_calibration(marker, blob) - if decoded is not None: - self._calibration = decoded + cal = _decode_lum_calibration(marker, blob) + if cal is not None: + self._calibration = cal return - decoded = _decode_lum_data(marker, blob) - if decoded is None: + data = _decode_lum_data(marker, blob) + if data is None: return - _label, _em, counts = decoded + _label, _em, counts = data if self._calibration is not None and self._dark_integration_s and self._meas_integration_s: intensity = _luminescence_intensity( self._calibration, counts, self._dark_integration_s, self._meas_integration_s From e487d0b2f6eee07ee7010d53a654283e79011aad Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 14:27:20 -0800 Subject: [PATCH 24/33] Rename marker to payload_len in infinite backend The "marker" name was misleading - it's actually the binary payload length extracted from protocol frames like "18,BIN:". The length happens to identify packet type (different packet types have characteristic sizes), but calling it "marker" obscured its true meaning. Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 112 +++++++++--------- .../tecan/infinite_backend_tests.py | 48 ++++---- 2 files changed, 82 insertions(+), 78 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 50c35a60cff..e87cf7bf663 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -40,19 +40,21 @@ def _integration_value_to_seconds(value: int) -> float: return value / 1_000_000.0 if value >= 1000 else value / 1000.0 -def _is_abs_calibration_marker(marker: int) -> bool: - return marker >= 22 and (marker - 4) % 18 == 0 +def _is_abs_calibration_len(payload_len: int) -> bool: + return payload_len >= 22 and (payload_len - 4) % 18 == 0 -def _is_abs_data_marker(marker: int) -> bool: - return marker >= 14 and (marker - 4) % 10 == 0 +def _is_abs_data_len(payload_len: int) -> bool: + return payload_len >= 14 and (payload_len - 4) % 10 == 0 -def _split_payload_and_trailer(marker: int, blob: bytes) -> Optional[Tuple[bytes, Tuple[int, int]]]: - if len(blob) != marker + 4: +def _split_payload_and_trailer( + payload_len: int, blob: bytes +) -> Optional[Tuple[bytes, Tuple[int, int]]]: + if len(blob) != payload_len + 4: return None - payload = blob[:marker] - trailer_reader = Reader(blob[marker:], little_endian=False) + payload = blob[:payload_len] + trailer_reader = Reader(blob[payload_len:], little_endian=False) return payload, (trailer_reader.u16(), trailer_reader.u16()) @@ -74,8 +76,8 @@ class _AbsorbanceCalibration: items: List[_AbsorbanceCalibrationItem] -def _decode_abs_calibration(marker: int, blob: bytes) -> Optional[_AbsorbanceCalibration]: - split = _split_payload_and_trailer(marker, blob) +def _decode_abs_calibration(payload_len: int, blob: bytes) -> Optional[_AbsorbanceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -103,8 +105,10 @@ def _decode_abs_calibration(marker: int, blob: bytes) -> Optional[_AbsorbanceCal return _AbsorbanceCalibration(ex=ex, items=items) -def _decode_abs_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: - split = _split_payload_and_trailer(marker, blob) +def _decode_abs_data( + payload_len: int, blob: bytes +) -> Optional[Tuple[int, int, List[Tuple[int, int]]]]: + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -176,8 +180,8 @@ class _FluorescenceCalibration: ref_bright: int -def _decode_flr_calibration(marker: int, blob: bytes) -> Optional[_FluorescenceCalibration]: - split = _split_payload_and_trailer(marker, blob) +def _decode_flr_calibration(payload_len: int, blob: bytes) -> Optional[_FluorescenceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -199,9 +203,9 @@ def _decode_flr_calibration(marker: int, blob: bytes) -> Optional[_FluorescenceC def _decode_flr_data( - marker: int, blob: bytes + payload_len: int, blob: bytes ) -> Optional[Tuple[int, int, int, List[Tuple[int, int]]]]: - split = _split_payload_and_trailer(marker, blob) + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -241,8 +245,8 @@ class _LuminescenceCalibration: ref_dark: int -def _decode_lum_calibration(marker: int, blob: bytes) -> Optional[_LuminescenceCalibration]: - split = _split_payload_and_trailer(marker, blob) +def _decode_lum_calibration(payload_len: int, blob: bytes) -> Optional[_LuminescenceCalibration]: + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -253,8 +257,8 @@ def _decode_lum_calibration(marker: int, blob: bytes) -> Optional[_LuminescenceC return _LuminescenceCalibration(ref_dark=reader.i32()) -def _decode_lum_data(marker: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: - split = _split_payload_and_trailer(marker, blob) +def _decode_lum_data(payload_len: int, blob: bytes) -> Optional[Tuple[int, int, List[int]]]: + split = _split_payload_and_trailer(payload_len, blob) if split is None: return None payload, _ = split @@ -322,7 +326,7 @@ class _StreamEvent: """Parsed stream event (ASCII or binary).""" text: Optional[str] = None - marker: Optional[int] = None + payload_len: Optional[int] = None blob: Optional[bytes] = None @@ -358,7 +362,7 @@ def feed(self, chunk: bytes) -> List[_StreamEvent]: break blob = bytes(self._buffer[:need]) del self._buffer[:need] - events.append(_StreamEvent(marker=self._pending_bin, blob=blob)) + events.append(_StreamEvent(payload_len=self._pending_bin, blob=blob)) self._pending_bin = None progressed = True continue @@ -416,18 +420,18 @@ def feed(self, chunk: bytes) -> None: if event.text is not None: if event.text == "ST": self._terminal_seen = True - elif event.marker is not None and event.blob is not None: - self.feed_bin(event.marker, event.blob) + elif event.payload_len is not None and event.blob is not None: + self.feed_bin(event.payload_len, event.blob) - def feed_bin(self, marker: int, blob: bytes) -> None: + def feed_bin(self, payload_len: int, blob: bytes) -> None: """Handle a binary payload if the decoder expects one.""" - if self._should_consume_bin(marker): - self._handle_bin(marker, blob) + if self._should_consume_bin(payload_len): + self._handle_bin(payload_len, blob) - def _should_consume_bin(self, _marker: int) -> bool: + def _should_consume_bin(self, _payload_len: int) -> bool: return False - def _handle_bin(self, _marker: int, _blob: bytes) -> None: + def _handle_bin(self, _payload_len: int, _blob: bytes) -> None: return None @@ -838,8 +842,8 @@ async def _await_measurements( ) -> None: target = decoder.count + row_count if self._pending_bin_events: - for marker, blob in self._pending_bin_events: - decoder.feed_bin(marker, blob) + for payload_len, blob in self._pending_bin_events: + decoder.feed_bin(payload_len, blob) self._pending_bin_events.clear() iterations = 0 while decoder.count < target and iterations < self._max_read_iterations: @@ -1125,8 +1129,8 @@ async def _read_command_response( frames.append(event.text) if self._is_terminal_frame(event.text): saw_terminal = True - elif event.marker is not None and event.blob is not None: - self._pending_bin_events.append((event.marker, event.blob)) + elif event.payload_len is not None and event.blob is not None: + self._pending_bin_events.append((event.payload_len, event.blob)) if not require_terminal and frames and not self._parser.has_pending_bin(): break if require_terminal and saw_terminal and not self._parser.has_pending_bin(): @@ -1168,19 +1172,19 @@ def calibration(self) -> Optional[_AbsorbanceCalibration]: """Return the absorbance calibration data, if available.""" return self._calibration - def _should_consume_bin(self, marker: int) -> bool: - return _is_abs_calibration_marker(marker) or _is_abs_data_marker(marker) + def _should_consume_bin(self, payload_len: int) -> bool: + return _is_abs_calibration_len(payload_len) or _is_abs_data_len(payload_len) - def _handle_bin(self, marker: int, blob: bytes) -> None: - if _is_abs_calibration_marker(marker): + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if _is_abs_calibration_len(payload_len): if self._calibration is not None: return - cal = _decode_abs_calibration(marker, blob) + cal = _decode_abs_calibration(payload_len, blob) if cal is not None: self._calibration = cal return - if _is_abs_data_marker(marker): - data = _decode_abs_data(marker, blob) + if _is_abs_data_len(payload_len): + data = _decode_abs_data(payload_len, blob) if data is None: return _label, _ex, items = data @@ -1209,20 +1213,20 @@ def intensities(self) -> List[int]: """Return decoded fluorescence intensities.""" return self._intensities - def _should_consume_bin(self, marker: int) -> bool: - if marker == 18: + def _should_consume_bin(self, payload_len: int) -> bool: + if payload_len == 18: return True - if marker >= 16 and (marker - 6) % 10 == 0: + if payload_len >= 16 and (payload_len - 6) % 10 == 0: return True return False - def _handle_bin(self, marker: int, blob: bytes) -> None: - if marker == 18: - cal = _decode_flr_calibration(marker, blob) + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if payload_len == 18: + cal = _decode_flr_calibration(payload_len, blob) if cal is not None: self._calibration = cal return - data = _decode_flr_data(marker, blob) + data = _decode_flr_data(payload_len, blob) if data is None: return _label, _ex, _em, items = data @@ -1261,20 +1265,20 @@ def __init__( def count(self) -> int: return len(self.measurements) - def _should_consume_bin(self, marker: int) -> bool: - if marker == 10: + def _should_consume_bin(self, payload_len: int) -> bool: + if payload_len == 10: return True - if marker >= 14 and (marker - 4) % 10 == 0: + if payload_len >= 14 and (payload_len - 4) % 10 == 0: return True return False - def _handle_bin(self, marker: int, blob: bytes) -> None: - if marker == 10: - cal = _decode_lum_calibration(marker, blob) + def _handle_bin(self, payload_len: int, blob: bytes) -> None: + if payload_len == 10: + cal = _decode_lum_calibration(payload_len, blob) if cal is not None: self._calibration = cal return - data = _decode_lum_data(marker, blob) + data = _decode_lum_data(payload_len, blob) if data is None: return _label, _em, counts = data diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 451792e8e4c..26d9d24bf1b 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -20,9 +20,9 @@ def _pack_u16(words): def _bin_blob(payload): - marker = len(payload) + payload_len = len(payload) trailer = b"\x00\x00\x00\x00" - return marker, payload + trailer + return payload_len, payload + trailer def _abs_calibration_blob(ex_decitenth, meas_dark, meas_bright, ref_dark, ref_bright): @@ -518,8 +518,8 @@ def _run_decoder_case(self, decoder, build_packet, extract_actual): expected_values = [] for well in self.scan_wells: intensity = self.grid[well.get_row()][well.get_column()] - marker, blob, expected = build_packet(intensity) - decoder.feed_bin(marker, blob) + payload_len, blob, expected = build_packet(intensity) + decoder.feed_bin(payload_len, blob) expected_values.append(expected) self.assertTrue(decoder.done) actual_values = extract_actual(decoder) @@ -532,14 +532,14 @@ def test_decode_absorbance_pattern(self): reference = 10000 max_absorbance = 1.0 decoder = _AbsorbanceRunDecoder(len(self.scan_wells)) - cal_marker, cal_blob = _abs_calibration_blob( + cal_len, cal_blob = _abs_calibration_blob( wavelength * 10, meas_dark=0, meas_bright=1000, ref_dark=0, ref_bright=1000, ) - decoder.feed_bin(cal_marker, cal_blob) + decoder.feed_bin(cal_len, cal_blob) cal = decoder.calibration self.assertIsNotNone(cal) @@ -548,9 +548,9 @@ def build_packet(intensity): if self.max_intensity: target = (intensity / self.max_intensity) * max_absorbance sample = max(1, int(round(reference / (10**target)))) - marker, blob = _abs_data_blob(wavelength * 10, sample, reference) + payload_len, blob = _abs_data_blob(wavelength * 10, sample, reference) expected = _absorbance_od_calibrated(cal, [(sample, reference)]) - return marker, blob, expected + return payload_len, blob, expected def extract_actual(decoder): return [ @@ -564,14 +564,14 @@ def test_decode_fluorescence_pattern(self): excitation = 485 emission = 520 decoder = _FluorescenceRunDecoder(len(self.scan_wells)) - cal_marker, cal_blob = _flr_calibration_blob( + cal_len, cal_blob = _flr_calibration_blob( excitation * 10, meas_dark=0, ref_dark=0, ref_bright=1000 ) - decoder.feed_bin(cal_marker, cal_blob) + decoder.feed_bin(cal_len, cal_blob) def build_packet(intensity): - marker, blob = _flr_data_blob(excitation * 10, emission * 10, intensity, 1000) - return marker, blob, intensity + payload_len, blob = _flr_data_blob(excitation * 10, emission * 10, intensity, 1000) + return payload_len, blob, intensity def extract_actual(decoder): return decoder.intensities @@ -582,8 +582,8 @@ def test_decode_luminescence_pattern(self): decoder = _LuminescenceRunDecoder(len(self.scan_wells)) def build_packet(intensity): - marker, blob = _lum_data_blob(0, intensity) - return marker, blob, intensity + payload_len, blob = _lum_data_blob(0, intensity) + return payload_len, blob, intensity def extract_actual(decoder): return [measurement.intensity for measurement in decoder.measurements] @@ -704,11 +704,11 @@ async def test_read_absorbance_commands(self): self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): - cal_marker, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) - decoder.feed_bin(cal_marker, cal_blob) + cal_len, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) for _ in range(row_count): - data_marker, data_blob = _abs_data_blob(6000, 500, 1000) - decoder.feed_bin(data_marker, data_blob) + data_len, data_blob = _abs_data_blob(6000, 500, 1000) + decoder.feed_bin(data_len, data_blob) with patch.object(self.backend, "_await_measurements", side_effect=mock_await): with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): @@ -759,11 +759,11 @@ async def test_read_fluorescence_commands(self): self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): - cal_marker, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) - decoder.feed_bin(cal_marker, cal_blob) + cal_len, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) + decoder.feed_bin(cal_len, cal_blob) for _ in range(row_count): - data_marker, data_blob = _flr_data_blob(4850, 5200, 500, 1000) - decoder.feed_bin(data_marker, data_blob) + data_len, data_blob = _flr_data_blob(4850, 5200, 500, 1000) + decoder.feed_bin(data_len, data_blob) with patch.object(self.backend, "_await_measurements", side_effect=mock_await): with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): @@ -836,8 +836,8 @@ async def mock_await(decoder, row_count, mode): cal_blob = bytes(14) decoder.feed_bin(10, cal_blob) for _ in range(row_count): - data_marker, data_blob = _lum_data_blob(0, 1000) - decoder.feed_bin(data_marker, data_blob) + data_len, data_blob = _lum_data_blob(0, 1000) + decoder.feed_bin(data_len, data_blob) with patch.object(self.backend, "_await_measurements", side_effect=mock_await): with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): From 3dceb240fc4e5d426be1f7090eeb15dea8cf915f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 14:40:06 -0800 Subject: [PATCH 25/33] Remove packet logging functionality from infinite backend Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 36 +------------------ 1 file changed, 1 insertion(+), 35 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index e87cf7bf663..4291c79ae53 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -7,15 +7,13 @@ from __future__ import annotations import asyncio -import json import logging import math -import os import re import time from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Dict, List, Optional, Sequence, TextIO, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from pylabrobot.io.binary import Reader from pylabrobot.io.usb import USB @@ -513,7 +511,6 @@ class TecanInfinite200ProBackend(PlateReaderBackend): def __init__( self, scan_config: Optional[InfiniteScanConfig] = None, - packet_log_path: Optional[str] = None, ) -> None: super().__init__() self.io = USB( @@ -533,9 +530,6 @@ def __init__( self._parser = _StreamParser(allow_bare_ascii=True) self._run_active = False self._active_step_loss_commands: List[str] = [] - self._packet_log_path = packet_log_path - self._packet_log_handle: Optional[TextIO] = None - self._packet_log_lock = asyncio.Lock() async def setup(self) -> None: async with self._setup_lock: @@ -554,9 +548,6 @@ async def stop(self) -> None: return await self._cleanup_protocol() await self.io.stop() - if self._packet_log_handle is not None: - self._packet_log_handle.close() - self._packet_log_handle = None self._device_initialized = False self._mode_capabilities.clear() self._reset_stream_state() @@ -963,8 +954,6 @@ async def _read_packet(self, size: int) -> bytes: except TimeoutError: await self._recover_transport() raise - if data: - await self._log_packet("in", data) return data async def _recover_transport(self) -> None: @@ -978,28 +967,6 @@ async def _recover_transport(self) -> None: self._mode_capabilities.clear() self._reset_stream_state() - async def _log_packet( - self, direction: str, data: bytes, ascii_payload: Optional[str] = None - ) -> None: - if not self._packet_log_path: - return - async with self._packet_log_lock: - if self._packet_log_handle is None: - parent = os.path.dirname(self._packet_log_path) - if parent: - os.makedirs(parent, exist_ok=True) - self._packet_log_handle = open(self._packet_log_path, "a", encoding="utf-8") - record = { - "ts": time.time(), - "dir": direction, - "size": len(data), - "data_hex": data.hex(), - } - if ascii_payload is not None: - record["ascii"] = ascii_payload - self._packet_log_handle.write(json.dumps(record) + "\n") - self._packet_log_handle.flush() - async def _end_run(self) -> None: try: await self._send_command("TERMINATE", allow_timeout=True) @@ -1085,7 +1052,6 @@ async def _send_command( logger.debug("[tecan] >> %s", command) framed = self._frame_command(command) await self.io.write(framed) - await self._log_packet("out", framed, ascii_payload=command) if not read_response: return [] if command.startswith(("#", "?")): From 073e45e17ffe0cd23aacac6536d325522fad1d90 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 14:46:41 -0800 Subject: [PATCH 26/33] type --- pylabrobot/plate_reading/tecan/infinite_backend_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 26d9d24bf1b..6f66e900cf9 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -541,7 +541,7 @@ def test_decode_absorbance_pattern(self): ) decoder.feed_bin(cal_len, cal_blob) cal = decoder.calibration - self.assertIsNotNone(cal) + assert cal is not None def build_packet(intensity): target = 0.0 From f407ae2dc4c8c57c9ce39357f5573c7b534bd19d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 24 Jan 2026 14:49:04 -0800 Subject: [PATCH 27/33] Fix Python 3.9 compatibility for asyncio.Lock in infinite backend Lazily initialize the asyncio.Lock to avoid "no current event loop" error on Python 3.9, which requires an event loop when creating locks. Co-Authored-By: Claude Opus 4.5 --- pylabrobot/plate_reading/tecan/infinite_backend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 4291c79ae53..b7aa8ddb723 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -520,7 +520,7 @@ def __init__( read_timeout=30, ) self.config = scan_config or InfiniteScanConfig() - self._setup_lock = asyncio.Lock() + self._setup_lock: Optional[asyncio.Lock] = None self._ready = False self._read_chunk_size = 512 self._max_read_iterations = 200 @@ -532,6 +532,8 @@ def __init__( self._active_step_loss_commands: List[str] = [] async def setup(self) -> None: + if self._setup_lock is None: + self._setup_lock = asyncio.Lock() async with self._setup_lock: if self._ready: return @@ -543,6 +545,8 @@ async def setup(self) -> None: self._ready = True async def stop(self) -> None: + if self._setup_lock is None: + self._setup_lock = asyncio.Lock() async with self._setup_lock: if not self._ready: return From 6c970ad18ccb6df11ac19eb0e11b47a31ccb8b47 Mon Sep 17 00:00:00 2001 From: hazlamshamin Date: Sat, 31 Jan 2026 00:24:49 +0800 Subject: [PATCH 28/33] Fix integration time unit conversion --- pylabrobot/plate_reading/tecan/infinite_backend.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index b7aa8ddb723..2bba8d5f529 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -34,8 +34,9 @@ class InfiniteScanConfig: counts_per_mm_y: float = 1_000 -def _integration_value_to_seconds(value: int) -> float: - return value / 1_000_000.0 if value >= 1000 else value / 1000.0 +def _integration_microseconds_to_seconds(value: int) -> float: + # DLL/UI indicates integration time is stored in microseconds; UI displays ms by dividing by 1000. + return value / 1_000_000.0 def _is_abs_calibration_len(payload_len: int) -> bool: @@ -805,8 +806,8 @@ async def read_luminescence( decoder = _LuminescenceRunDecoder( len(scan_wells), - dark_integration_s=_integration_value_to_seconds(dark_integration), - meas_integration_s=_integration_value_to_seconds(meas_integration), + dark_integration_s=_integration_microseconds_to_seconds(dark_integration), + meas_integration_s=_integration_microseconds_to_seconds(meas_integration), ) await self._run_scan( From 9ea153a54a3745180d234743bcd60765be89a100 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:02:18 -0800 Subject: [PATCH 29/33] Use focal_height parameter for Z position in fluorescence and luminescence Co-Authored-By: Claude Opus 4.5 --- .../plate_reading/tecan/infinite_backend.py | 21 ++++++++++++------- .../tecan/infinite_backend_tests.py | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 2bba8d5f529..948ebdb301c 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -32,6 +32,7 @@ class InfiniteScanConfig: flashes: int = 25 counts_per_mm_x: float = 1_000 counts_per_mm_y: float = 1_000 + counts_per_mm_z: float = 1_000 def _integration_microseconds_to_seconds(value: int) -> float: @@ -720,7 +721,7 @@ async def read_fluorescence( await self._begin_run() try: - await self._configure_fluorescence(excitation_wavelength, emission_wavelength) + await self._configure_fluorescence(excitation_wavelength, emission_wavelength, focal_height) decoder = _FluorescenceRunDecoder(len(scan_wells)) await self._run_scan( @@ -752,11 +753,14 @@ async def read_fluorescence( finally: await self._end_run() - async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> None: + async def _configure_fluorescence( + self, excitation_nm: int, emission_nm: int, focal_height: float + ) -> None: ex_decitenth = int(round(excitation_nm * 10)) em_decitenth = int(round(emission_nm * 10)) reads_number = max(1, int(self.config.flashes)) beam_diameter = self._capability_numeric("FI.TOP", "#BEAM DIAMETER", 3000) + z_position = int(round(focal_height * self.config.counts_per_mm_z)) # UI issues the entire FI configuration twice before PREPARE REF. for _ in range(2): @@ -768,7 +772,7 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> await self._send_command("TIME 0,LAG=0", allow_timeout=True) await self._send_command("TIME 0,READDELAY=0", allow_timeout=True) await self._send_command("GAIN 0,VALUE=100", allow_timeout=True) - await self._send_command("POSITION 0,Z=20000", allow_timeout=True) + await self._send_command(f"POSITION 0,Z={z_position}", allow_timeout=True) await self._send_command(f"BEAM DIAMETER={beam_diameter}", allow_timeout=True) await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) await self._send_command("RATIO LABELS=1", allow_timeout=True) @@ -779,7 +783,7 @@ async def _configure_fluorescence(self, excitation_nm: int, emission_nm: int) -> await self._send_command("TIME 1,LAG=0", allow_timeout=True) await self._send_command("TIME 1,READDELAY=0", allow_timeout=True) await self._send_command("GAIN 1,VALUE=100", allow_timeout=True) - await self._send_command("POSITION 1,Z=20000", allow_timeout=True) + await self._send_command(f"POSITION 1,Z={z_position}", allow_timeout=True) await self._send_command(f"READS 1,NUMBER={reads_number}", allow_timeout=True) await self._send_command("PREPARE REF", allow_timeout=True, read_response=False) @@ -802,7 +806,7 @@ async def read_luminescence( await self._begin_run() try: - await self._configure_luminescence(dark_integration, meas_integration) + await self._configure_luminescence(dark_integration, meas_integration, focal_height) decoder = _LuminescenceRunDecoder( len(scan_wells), @@ -856,7 +860,9 @@ async def _await_scan_terminal(self, saw_terminal: bool) -> None: return await self._read_command_response() - async def _configure_luminescence(self, dark_integration: int, meas_integration: int) -> None: + async def _configure_luminescence( + self, dark_integration: int, meas_integration: int, focal_height: float + ) -> None: await self._send_command("MODE LUM") # Pre-flight safety checks observed in captures (queries omitted). await self._send_command("CHECK LUM.FIBER") @@ -864,8 +870,9 @@ async def _configure_luminescence(self, dark_integration: int, meas_integration: await self._send_command("CHECK LUM.STEPLOSS") await self._send_command("MODE LUM") reads_number = max(1, int(self.config.flashes)) + z_position = int(round(focal_height * self.config.counts_per_mm_z)) await self._clear_mode_settings(emission=True) - await self._send_command("POSITION LUM,Z=14620", allow_timeout=True) + await self._send_command(f"POSITION LUM,Z={z_position}", allow_timeout=True) await self._send_command(f"TIME 0,INTEGRATION={dark_integration}", allow_timeout=True) await self._send_command(f"READS 0,NUMBER={reads_number}", allow_timeout=True) await self._send_command("SCAN DIRECTION=UP", allow_timeout=True) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index 6f66e900cf9..f0916ad3085 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -841,7 +841,7 @@ async def mock_await(decoder, row_count, mode): with patch.object(self.backend, "_await_measurements", side_effect=mock_await): with patch.object(self.backend, "_await_scan_terminal", new_callable=AsyncMock): - await self.backend.read_luminescence(self.plate, [], focal_height=20.0) + await self.backend.read_luminescence(self.plate, [], focal_height=14.62) self.mock_usb.write.assert_has_calls( [ From 738ca07a8b964d536ddcfafba6e2c99fa3a538c7 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:29:16 -0800 Subject: [PATCH 30/33] Remove _device_initialized flag and simplify initialization - Remove misleading _device_initialized flag that didn't reflect actual device responsiveness - Remove redundant _initialize_device() call from _begin_run() - Call _initialize_device() directly in _recover_transport() after USB recovery Co-Authored-By: Claude Opus 4.5 --- pylabrobot/plate_reading/tecan/infinite_backend.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/plate_reading/tecan/infinite_backend.py index 948ebdb301c..63c6840fd2b 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend.py @@ -526,7 +526,6 @@ def __init__( self._ready = False self._read_chunk_size = 512 self._max_read_iterations = 200 - self._device_initialized = False self._mode_capabilities: Dict[str, Dict[str, str]] = {} self._pending_bin_events: List[Tuple[int, bytes]] = [] self._parser = _StreamParser(allow_bare_ascii=True) @@ -554,7 +553,6 @@ async def stop(self) -> None: return await self._cleanup_protocol() await self.io.stop() - self._device_initialized = False self._mode_capabilities.clear() self._reset_stream_state() self._ready = False @@ -941,17 +939,13 @@ def _format_plate_result( return matrix async def _initialize_device(self) -> None: - if self._device_initialized: - return try: await self._send_command("QQ") except TimeoutError: logger.warning("QQ produced no response; continuing with initialization.") await self._send_command("INIT FORCE") - self._device_initialized = True async def _begin_run(self) -> None: - await self._initialize_device() self._reset_stream_state() await self._send_command("KEYLOCK ON") self._run_active = True @@ -975,9 +969,9 @@ async def _recover_transport(self) -> None: await self.io.setup() except Exception: return - self._device_initialized = False self._mode_capabilities.clear() self._reset_stream_state() + await self._initialize_device() async def _end_run(self) -> None: try: From 9b45fa68edc2a9fecfa9ecf1efbea85d48901c48 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:33:45 -0800 Subject: [PATCH 31/33] Remove _device_initialized references from tests Co-Authored-By: Claude Opus 4.5 --- pylabrobot/plate_reading/tecan/infinite_backend_tests.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py index f0916ad3085..859456693e4 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/plate_reading/tecan/infinite_backend_tests.py @@ -674,7 +674,6 @@ def _frame(self, command: str) -> bytes: async def test_open(self): self.backend._ready = True - self.backend._device_initialized = True await self.backend.open() @@ -687,7 +686,6 @@ async def test_open(self): async def test_close(self): self.backend._ready = True - self.backend._device_initialized = True await self.backend.close(self.plate) @@ -701,7 +699,6 @@ async def test_close(self): async def test_read_absorbance_commands(self): """Test that read_absorbance sends the correct configuration commands.""" self.backend._ready = True - self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): cal_len, cal_blob = _abs_calibration_blob(6000, 0, 1000, 0, 1000) @@ -756,7 +753,6 @@ async def mock_await(decoder, row_count, mode): async def test_read_fluorescence_commands(self): """Test that read_fluorescence sends the correct configuration commands.""" self.backend._ready = True - self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): cal_len, cal_blob = _flr_calibration_blob(4850, 0, 0, 1000) @@ -830,7 +826,6 @@ async def mock_await(decoder, row_count, mode): async def test_read_luminescence_commands(self): """Test that read_luminescence sends the correct configuration commands.""" self.backend._ready = True - self.backend._device_initialized = True async def mock_await(decoder, row_count, mode): cal_blob = bytes(14) From 156bc63e01c5294a97b884226281dc6b0dff2603 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 31 Jan 2026 10:04:34 -0800 Subject: [PATCH 32/33] Add Tecan Infinite 200 PRO to machine overview Co-Authored-By: Claude Opus 4.5 --- docs/user_guide/machines.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index cc6eee2a739..b987ab7c301 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -159,6 +159,7 @@ tr > td:nth-child(5) { width: 15%; } | 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) | +| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [OEM](https://lifesciences.tecan.com/infinite-200-pro) | ### Flow Cytometers From a4fb8efedafb5c76b9fa361853d6ca7618a963a5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 31 Jan 2026 10:09:13 -0800 Subject: [PATCH 33/33] Add Tecan Infinite 200 PRO tutorial documentation Co-Authored-By: Claude Opus 4.5 --- .../plate-reading/plate-reading.ipynb | 18 +- .../plate-reading/tecan-infinite.ipynb | 186 ++++++++++++++++++ docs/user_guide/machines.md | 2 +- 3 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb diff --git a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb index d35458037bd..dd05196bf7a 100644 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb @@ -4,21 +4,7 @@ "cell_type": "markdown", "id": "39d0c1a5", "metadata": {}, - "source": [ - "# Plate reading\n", - "\n", - "PyLabRobot supports the following plate readers:\n", - "\n", - "```{toctree}\n", - ":maxdepth: 1\n", - "\n", - "bmg-clariostar\n", - "cytation\n", - "synergyh1\n", - "```\n", - "\n", - "This example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." - ] + "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbmg-clariostar\ncytation\nsynergyh1\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." }, { "cell_type": "code", @@ -432,4 +418,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb b/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb new file mode 100644 index 00000000000..87e19bd0bf8 --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/tecan-infinite.ipynb @@ -0,0 +1,186 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tecan Infinite 200 PRO\n", + "\n", + "The Tecan Infinite 200 PRO is a multimode microplate reader that supports absorbance, fluorescence, and luminescence measurements. This backend targets the Infinite \"M\" series (e.g., Infinite 200 PRO M Plex)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading import PlateReader\n", + "from pylabrobot.plate_reading.tecan import TecanInfinite200ProBackend" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pr = PlateReader(name=\"PR\", size_x=0, size_y=0, size_z=0, backend=TecanInfinite200ProBackend())\n", + "await pr.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.open()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Before closing, assign a plate to the plate reader. This determines the well positions for measurements." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "pr.assign_child_resource(plate)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "Read absorbance at a specified wavelength (230-1000 nm)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "data = await pr.read_absorbance(wavelength=450)\n", + "plt.imshow(data)\n", + "plt.colorbar(label=\"OD\")\n", + "plt.title(\"Absorbance at 450 nm\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence\n", + "\n", + "Read fluorescence with specified excitation and emission wavelengths (230-850 nm) and focal height." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data = await pr.read_fluorescence(\n", + " excitation_wavelength=485,\n", + " emission_wavelength=528,\n", + " focal_height=7.5\n", + ")\n", + "plt.imshow(data)\n", + "plt.colorbar(label=\"RFU\")\n", + "plt.title(\"Fluorescence (Ex: 485 nm, Em: 528 nm)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence\n", + "\n", + "Read luminescence with a specified focal height." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "data = await pr.read_luminescence(focal_height=4.5)\n", + "plt.imshow(data)\n", + "plt.colorbar(label=\"RLU\")\n", + "plt.title(\"Luminescence\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reading specific wells\n", + "\n", + "You can specify a subset of wells to read instead of the entire plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "wells = plate.get_items([\"A1\", \"A2\", \"B1\", \"B2\"])\n", + "data = await pr.read_absorbance(wavelength=450, wells=wells)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index b987ab7c301..814b1442240 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -159,7 +159,7 @@ tr > td:nth-child(5) { width: 15%; } | 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) | -| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [OEM](https://lifesciences.tecan.com/infinite-200-pro) | +| Tecan | Infinite 200 PRO | absorbancefluorescenceluminescence | Mostly | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/tecan-infinite.html) / [OEM](https://lifesciences.tecan.com/infinite-200-pro) | ### Flow Cytometers