From 9a5060622871bcee8092270a199d8d82cee55de2 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Tue, 13 Jan 2026 10:32:46 +0100 Subject: [PATCH 01/35] added first version of backend --- pylabrobot/storage/liconic/liconic_backend.py | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 pylabrobot/storage/liconic/liconic_backend.py diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py new file mode 100644 index 00000000000..691dc6d1913 --- /dev/null +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -0,0 +1,290 @@ +import asyncio +import logging +import time +import warnings +from typing import List, Tuple, Optional + +import serial + +from pylabrobot.io.serial import Serial +from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.carrier import PlateCarrier +from pylabrobot.storage.backend import IncubatorBackend + +logger = logging.getLogger(__name__) + +class LiconicBackend(IncubatorBackend): + """ + Backend for Liconic incubators. + Written to connect with internal barcode reader and gas control. + Barcode reader tested is the Keyence BL-1300 + """ + + default_baud = 9600 + serial_message_encoding = "ascii" + init_timeout = 1.0 + start_timeout = 15.0 + poll_interval = 0.2 + + def __init__(self, port: str): + super().__init__() + self.io_plc = Serial( + port=port, + baudrate=self.default_baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + write_timeout=1, + timeout=1, + rtscts=True, + ) + + self.barcode_installed: Optional[bool] = None + + # BL-1300 Barcode reader factory default serial communication settings + # should be the same factory default for the BL-600HA and BL-1300 models + self.io_bcr = Serial( + port=port, + baudrate=self.default_baud, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + write_timeout=1, + timeout=1, + rtscts=True, + ) + + self.co2_installed: Optional[bool] = None + self.n2_installed: Optional[bool] = None + + async def setup_plc(self) -> Serial: + """ + 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. + 2. Send >200 ms break, wait 150 ms, flush buffers. + 3. Handshake: CR → wait for CC + 4. Activate handling: ST 1801 → expect OK + 5. Poll ready-flag: RD 1915 → wait for "1" + """ + try: + await self.io_plc.setup() + except serial.SerialException as e: + raise RuntimeError(f"Could not open {self.io_plc.port}: {e}") + + await self.io_plc.send_break(duration=0.2) # >100 ms required + await asyncio.sleep(0.15) + await self.io_plc.reset_input_buffer() + await self.io_plc.reset_output_buffer() + + await self.io_plc.write(b"CR\r") + deadline = time.time() + self.init_timeout + while time.time() < deadline: + resp = await self.io_plc.readline() # reads through LF + if resp.strip() == b"CC": + break + else: + await self.io_plc.stop() + raise TimeoutError(f"No CC response from Liconic PLC within {self.init_timeout} seconds") + + await self.io.write(b"ST 1801\r") + resp = await self.io.readline() + if resp.strip() != b"OK": + await self.io.stop() + raise RuntimeError(f"Unexpected reply to ST 1801: {resp!r}") + + deadline = time.time() + self.start_timeout + while time.time() < deadline: + await self.io_plc.write(b"RD 1915\r") + flag = await self.io_plc.readline() + if flag.strip() == b"1": + return self.io_plc + await asyncio.sleep(self.poll_interval) + + await self.io_plc.stop() + raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") + + async def stop_plc(self): + await self.io_plc.stop() + + async def setup_bcr(self) -> Serial: + """ + Setup barcode reader serial connection. + Liconic uses the Keyence BL-1300 barcode reader in older systems and BL-600HA in newer systems. + 1. Open serial port (9600 7E1, RTS/CTS) via the Serial wrapper. + 2. Send >200 ms break, wait 150 ms, + """ + try: + await self.io_bcr.setup() + except serial.SerialException as e: + raise RuntimeError(f"Could not open {self.io_bcr.port}: {e}") + + await self.io_bcr.send_break(duration=0.2) # >100 ms required + await asyncio.sleep(0.15) + await self.io_bcr.reset_input_buffer() + await self.io_bcr.reset_output_buffer() + + await self.io_bcr.write(b"RMOTOR\r") + deadline = time.time() + self.start_timeout + while time.time() < deadline: + resp = await self.io_bcr.readline() + if resp.strip() == b"MOTORON": + return self.io_bcr + await asyncio.sleep(self.poll_interval) + + await self.io_bcr.stop() + raise TimeoutError(f"Barcode reader did not respond with MOTORON within {self.start_timeout} seconds") + + async def stop_bcr(self): + await self.io_bcr.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + warnings.warn("Liconic racks need to be configured manually on each setup") + + async def initialize(self): + await self._send_command_plc("ST 1900") + await self._send_command_plc("ST 1801") + await self._wait_ready() + + async def open_door(self): + await self._send_command_plc("ST 1901") + await self._wait_ready() + + async def close_door(self): + await self._send_command_plc("ST 1902") + await self._wait_ready() + + async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read_barcode: Optional[bool]=False): + """ Fetch a plate from the incubator to the loading tray.""" + site = plate.parent + assert isinstance(site, PlateHolder), "Plate not in storage" + m, n = self._site_to_m_n(site) + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel + + if self.barcode_installed and read_barcode: + await self._send_command_plc("ST 1910") # move shovel to barcode reading position + await self._wait_ready() + barcode = await self._send_command_bcr("LON") # read barcode, need to check if this needs a timeout level signal trigger vs. one-shot read + if barcode is None: + raise RuntimeError("Failed to read barcode from plate") + elif barcode == "ERROR": + logger.info(f"No barcode found when reading plate at cassette {m}, position {n}") + else: + logger.info(f"Read barcode from plate at cassette {m}, position {n}: {barcode}") + reset = await self._send_command_plc("RS 1910") # move shovel back to normal position + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") + await self._wait_ready() + elif read_barcode and not self.barcode_installed: + logger.info(" Barcode reading requested during export but instance not configured with barcode reader.") + + await self._send_command_plc("ST 1905") # plate to transfer station + await self._wait_ready() + await self._send_command_plc("ST 1903") # terminate access + + async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False): + """ Take in a plate from the loading tray to the incubator.""" + m, n = self._site_to_m_n(site) + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel + await self._send_command_plc("ST 1904") # plate from transfer station + await self._wait_ready() + + if self.barcode_installed and read_barcode: + await self._send_command_plc("ST 1910") # move shovel to barcode reading position + await self._wait_ready() + barcode = await self._send_command_bcr("LON") # read barcode + if barcode is None: + raise RuntimeError("Failed to read barcode from plate") + elif barcode == "ERROR": + logger.info(f"No barcode found when reading plate at cassette {m}, position {n}") + else: + logger.info(f"Read barcode from plate at cassette {m}, position {n}: {barcode}") + reset = await self._send_command_plc("RS 1910") # move shovel back to normal position + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") + await self._wait_ready() + elif read_barcode and not self.barcode_installed: + logger.info(" Barcode reading requested during import but instance not configured with barcode reader.") + + await self._send_command_plc("ST 1903") # terminate access + + async def _send_command_plc(self, command: str) -> str: + """ + Send an ASCII command to the Liconic PLC over serial and return the response. + """ + cmd = command.strip() + "\r" + logger.debug(f"Sending command to Liconic PLC: {cmd!r}") + await self.io_plc.write(cmd.encode(self.serial_message_encoding)) + resp = (await self.io_plc.read(128)).decode(self.serial_message_encoding) + if not resp: + raise RuntimeError(f"No response from Liconic PLC for command {command!r}") + resp = resp.strip() + if resp.startswith("E"): + # add Liconic error handling message decoding here + raise RuntimeError(f"Error response from Liconic PLC for command {command!r}: {resp!r}") + return resp + + async def _send_command_bcr(self, command: str) -> str: + """ + Send an ASCII command to the barcode reader over serial and return the response. + """ + cmd = command.strip() + "\r" + logger.debug(f"Sending command to Barcode Reader: {cmd!r}") + await self.io_bcr.write(cmd.encode(self.serial_message_encoding)) + resp = (await self.io_bcr.read(128)).decode(self.serial_message_encoding) + if not resp: + raise RuntimeError(f"No response from Barcode Reader for command {command!r}") + resp = resp.strip() + if resp.startswith("NG"): + raise RuntimeError("Barcode reader is off: cannot read barcode") + elif resp.startswith("ERR99"): + raise RuntimeError(f"Error response from Barcode Reader for command {command!r}: {resp!r}") + return resp + + async def _wait_ready(self, timeout: int = 60): + """ + Poll the ready-flag (RD 1915) until it is set, or timeout is reached. + """ + start = time.time() + deadline = start + timeout + while time.time() < deadline: + resp = await self._send_command("RD 1915") + if resp == "1": + return + await asyncio.sleep(0.1) + raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") + + async def set_temperature(self, temperature: float): + """ Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt + where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370) """ + temp_value = int(temperature * 10) + temp_str = str(temp_value).zfill(5) + await self._send_command_plc(f"WR DM890 {temp_str}") + await self._wait_ready() + + async def get_temperature(self) -> float: + """ Get the temperature of the incubator in degrees Celsius. Using command RD DM982 """ + resp = await self._send_command_plc("RD DM982") + try: + temp_value = int(resp) + temperature = temp_value / 10.0 + return temperature + except ValueError: + raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") + + async def start_shaking(self, frequency: float = 10.0): + """ Start shaking. Frequency by default is 10 Hz. Using command ST 1913. This functionality is + not currently able to be tested. """ + if frequency < 1.0 or frequency > 50.0: + raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") + else: + frequency_value = int(frequency) # assuming incubator expects frequency in 0.1 Hz units + await self._send_command_plc(f"WR DM39 {frequency_value}") + await self._send_command_plc("ST 1913") + await self._wait_ready() + + async def stop_shaking(self): + """ Stop shaking. Using command RS 1913 """ + await self._send_command_plc("RS 1913") + await self._wait_ready() From 8d2500bd58c1c9f57ab1724dd9a49c782f69ad89 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Tue, 13 Jan 2026 10:44:53 +0100 Subject: [PATCH 02/35] init file --- pylabrobot/storage/liconic/_init_.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 pylabrobot/storage/liconic/_init_.py diff --git a/pylabrobot/storage/liconic/_init_.py b/pylabrobot/storage/liconic/_init_.py new file mode 100644 index 00000000000..99b93f73f0c --- /dev/null +++ b/pylabrobot/storage/liconic/_init_.py @@ -0,0 +1 @@ +from .liconic_backend import LiconicBackend From d5c06b454ca4fc5b4f9e9494a2755729e857e896 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Tue, 13 Jan 2026 11:20:48 +0100 Subject: [PATCH 03/35] init updates and backend bug fixes --- pylabrobot/storage/__init__.py | 1 + .../storage/liconic/{_init_.py => __init__.py} | 0 pylabrobot/storage/liconic/liconic_backend.py | 16 ++++++++++------ 3 files changed, 11 insertions(+), 6 deletions(-) rename pylabrobot/storage/liconic/{_init_.py => __init__.py} (100%) diff --git a/pylabrobot/storage/__init__.py b/pylabrobot/storage/__init__.py index 0a987a7df1f..0a6f5fb87ac 100644 --- a/pylabrobot/storage/__init__.py +++ b/pylabrobot/storage/__init__.py @@ -2,4 +2,5 @@ from .chatterbox import IncubatorChatterboxBackend from .cytomat import CytomatBackend from .incubator import Incubator +from .liconic import LiconicBackend # from .inheco import * diff --git a/pylabrobot/storage/liconic/_init_.py b/pylabrobot/storage/liconic/__init__.py similarity index 100% rename from pylabrobot/storage/liconic/_init_.py rename to pylabrobot/storage/liconic/__init__.py diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 691dc6d1913..00262b65f41 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -51,13 +51,13 @@ def __init__(self, port: str): stopbits=serial.STOPBITS_ONE, write_timeout=1, timeout=1, - rtscts=True, + rtscts=False, ) self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None - async def setup_plc(self) -> Serial: + async def setup(self) -> Serial: """ 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. 2. Send >200 ms break, wait 150 ms, flush buffers. @@ -85,10 +85,10 @@ async def setup_plc(self) -> Serial: await self.io_plc.stop() raise TimeoutError(f"No CC response from Liconic PLC within {self.init_timeout} seconds") - await self.io.write(b"ST 1801\r") - resp = await self.io.readline() + await self.io_plc.write(b"ST 1801\r") + resp = await self.io_plc.readline() if resp.strip() != b"OK": - await self.io.stop() + await self.io_plc.stop() raise RuntimeError(f"Unexpected reply to ST 1801: {resp!r}") deadline = time.time() + self.start_timeout @@ -102,6 +102,10 @@ async def setup_plc(self) -> Serial: await self.io_plc.stop() raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") + async def stop(self): + await self.io_plc.stop() + await self.io_bcr.stop() + async def stop_plc(self): await self.io_plc.stop() @@ -249,7 +253,7 @@ async def _wait_ready(self, timeout: int = 60): start = time.time() deadline = start + timeout while time.time() < deadline: - resp = await self._send_command("RD 1915") + resp = await self._send_command_plc("RD 1915") if resp == "1": return await asyncio.sleep(0.1) From 19a6cefb85f5ae9b841c7147e798bded5a09a11a Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 14 Jan 2026 14:51:12 +0100 Subject: [PATCH 04/35] Adding Barcode scanning --- pylabrobot/barcode_scanners/__init__.py | 2 + pylabrobot/barcode_scanners/backend.py | 15 +++++ .../barcode_scanners/keyence/__init__.py | 1 + .../keyence/barcode_scanner_backend.py | 64 +++++++++++++++++++ pylabrobot/storage/liconic/liconic_backend.py | 24 ++----- 5 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 pylabrobot/barcode_scanners/__init__.py create mode 100644 pylabrobot/barcode_scanners/backend.py create mode 100644 pylabrobot/barcode_scanners/keyence/__init__.py create mode 100644 pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py diff --git a/pylabrobot/barcode_scanners/__init__.py b/pylabrobot/barcode_scanners/__init__.py new file mode 100644 index 00000000000..d562fe1c6bb --- /dev/null +++ b/pylabrobot/barcode_scanners/__init__.py @@ -0,0 +1,2 @@ +from .backend import BarcodeScannerBackend +from .keyence import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/barcode_scanners/backend.py new file mode 100644 index 00000000000..5f1a3758b57 --- /dev/null +++ b/pylabrobot/barcode_scanners/backend.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.machines.backend import MachineBackend + +class BarcodeScannerError(Exception): + """Error raised by a barcode scanner backend.""" + +class BarcodeScannerBackend(MachineBackend, metaclass=ABCMeta): + def __init__(self): + super().__init__() + + @abstractmethod + async def scan_barcode(self) -> str: + """Scan a barcode and return its value as a string.""" + pass diff --git a/pylabrobot/barcode_scanners/keyence/__init__.py b/pylabrobot/barcode_scanners/keyence/__init__.py new file mode 100644 index 00000000000..201adcce336 --- /dev/null +++ b/pylabrobot/barcode_scanners/keyence/__init__.py @@ -0,0 +1 @@ +from .barcode_scanner_backend import BarcodeScannerBackend diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py new file mode 100644 index 00000000000..77266f7f234 --- /dev/null +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -0,0 +1,64 @@ +from pylabrobot.barcode_scanners.backend import ( + BarcodeScannerBackend, + BarcodeScannerError, +) + +import serial +import time + +from pylabrobot.io.serial import Serial + +class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): + default_baudrate = 9600 + serial_messaging_encoding = "ascii" + init_timeout = 1.0 # seconds + + def __init__(self, serial_port: str,): + super().__init__() + + # BL-1300 Barcode reader factory default serial communication settings + # should be the same factory default for the BL-600HA and BL-1300 models + self.io = Serial( + port=serial_port, + baudrate=self.default_baudrate, + bytesize=serial.SEVENBITS, + parity=serial.PARITY_EVEN, + stopbits=serial.STOPBITS_ONE, + write_timeout=1, + timeout=1, + rtscts=False, + ) + + async def setup(self): + await self.io.setup() + await self.initialize_scanner() + + async def initialize_scanner(self): + """Initialize the Keyence barcode scanner.""" + + response = await self.send_command("RMOTOR") + + deadline = time.time() + self.init_timeout + while time.time() < deadline: + response = await self.send_command("RMOTOR") + if response.strip() == "MOTORON": + break + elif response.strip() == "MOTOROFF": + raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.") + else: + raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: " \ + "Timeout waiting for motor to turn on.") + + async def send_command(self, command: str) -> str: + """Send a command to the barcode scanner and return the response. + Keyence uses carriage return \r as the line ending by default.""" + + await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) + response = await self.io.readline() + return response.decode(self.serial_messaging_encoding).strip() + + async def stop(self): + await self.io.stop() + + async def scan_barcode(self) -> str: + return await self.send_command("LON") diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 00262b65f41..a1e3343d635 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -10,6 +10,7 @@ from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend +from pylabrobot.barcode_scanners.keyence.barcode_scanner_backend import KeyenceBarcodeScannerBackend logger = logging.getLogger(__name__) @@ -26,8 +27,11 @@ class LiconicBackend(IncubatorBackend): start_timeout = 15.0 poll_interval = 0.2 - def __init__(self, port: str): + def __init__(self, port: str, barcode_installed: Optional[bool] = None, barcode_port: Optional[str] = None): super().__init__() + + self.barcode_installed: Optional[bool] = barcode_installed + self.io_plc = Serial( port=port, baudrate=self.default_baud, @@ -39,21 +43,6 @@ def __init__(self, port: str): rtscts=True, ) - self.barcode_installed: Optional[bool] = None - - # BL-1300 Barcode reader factory default serial communication settings - # should be the same factory default for the BL-600HA and BL-1300 models - self.io_bcr = Serial( - port=port, - baudrate=self.default_baud, - bytesize=serial.SEVENBITS, - parity=serial.PARITY_EVEN, - stopbits=serial.STOPBITS_ONE, - write_timeout=1, - timeout=1, - rtscts=False, - ) - self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None @@ -106,9 +95,6 @@ async def stop(self): await self.io_plc.stop() await self.io_bcr.stop() - async def stop_plc(self): - await self.io_plc.stop() - async def setup_bcr(self) -> Serial: """ Setup barcode reader serial connection. From 93602d24995d24a0151f6225643d86ce51cf4f52 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Wed, 14 Jan 2026 15:02:19 +0100 Subject: [PATCH 05/35] fixed import for Keyence --- pylabrobot/barcode_scanners/keyence/__init__.py | 2 +- pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pylabrobot/barcode_scanners/keyence/__init__.py b/pylabrobot/barcode_scanners/keyence/__init__.py index 201adcce336..7f99f5acbdd 100644 --- a/pylabrobot/barcode_scanners/keyence/__init__.py +++ b/pylabrobot/barcode_scanners/keyence/__init__.py @@ -1 +1 @@ -from .barcode_scanner_backend import BarcodeScannerBackend +from .barcode_scanner_backend import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index 77266f7f234..daab57ab87b 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -42,6 +42,7 @@ async def initialize_scanner(self): while time.time() < deadline: response = await self.send_command("RMOTOR") if response.strip() == "MOTORON": + print("Barcode scanner motor is ON.") break elif response.strip() == "MOTOROFF": raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.") From ad0510720168f0f46d8840a3618fdaabcfc379e4 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 14 Jan 2026 15:49:00 +0100 Subject: [PATCH 06/35] Updated Liconic Backend to use KeyenceBarcodeScannerBackend --- .../keyence/barcode_scanner_backend.py | 3 ++ pylabrobot/storage/liconic/liconic_backend.py | 36 ++++++++----------- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index 77266f7f234..dfe2b263842 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -1,3 +1,4 @@ +import asyncio from pylabrobot.barcode_scanners.backend import ( BarcodeScannerBackend, BarcodeScannerError, @@ -12,6 +13,7 @@ class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): default_baudrate = 9600 serial_messaging_encoding = "ascii" init_timeout = 1.0 # seconds + poll_interval = 0.2 # seconds def __init__(self, serial_port: str,): super().__init__() @@ -45,6 +47,7 @@ async def initialize_scanner(self): break elif response.strip() == "MOTOROFF": raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.") + await asyncio.sleep(self.poll_interval) else: raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: " \ "Timeout waiting for motor to turn on.") diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index a1e3343d635..37128e7c35d 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -10,7 +10,7 @@ from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.barcode_scanners.keyence.barcode_scanner_backend import KeyenceBarcodeScannerBackend +from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend logger = logging.getLogger(__name__) @@ -31,6 +31,7 @@ def __init__(self, port: str, barcode_installed: Optional[bool] = None, barcode_ super().__init__() self.barcode_installed: Optional[bool] = barcode_installed + self.barcode_port: Optional[str] = barcode_port self.io_plc = Serial( port=port, @@ -46,6 +47,7 @@ def __init__(self, port: str, barcode_installed: Optional[bool] = None, barcode_ self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None + # Function to setup serial connection with Liconic PLC async def setup(self) -> Serial: """ 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. @@ -93,35 +95,25 @@ async def setup(self) -> Serial: async def stop(self): await self.io_plc.stop() - await self.io_bcr.stop() - async def setup_bcr(self) -> Serial: + async def setup_bcr(self, barcode_installed: bool, barcode_port: str) -> KeyenceBarcodeScannerBackend: """ Setup barcode reader serial connection. Liconic uses the Keyence BL-1300 barcode reader in older systems and BL-600HA in newer systems. 1. Open serial port (9600 7E1, RTS/CTS) via the Serial wrapper. - 2. Send >200 ms break, wait 150 ms, """ - try: - await self.io_bcr.setup() - except serial.SerialException as e: - raise RuntimeError(f"Could not open {self.io_bcr.port}: {e}") - await self.io_bcr.send_break(duration=0.2) # >100 ms required - await asyncio.sleep(0.15) - await self.io_bcr.reset_input_buffer() - await self.io_bcr.reset_output_buffer() - - await self.io_bcr.write(b"RMOTOR\r") - deadline = time.time() + self.start_timeout - while time.time() < deadline: - resp = await self.io_bcr.readline() - if resp.strip() == b"MOTORON": - return self.io_bcr - await asyncio.sleep(self.poll_interval) + if not barcode_installed: + raise RuntimeError("Liconic instance initialized with barcode scanner as false") + elif barcode_port is None: + raise RuntimeError("Liconic instance initialized with barcode scanner but no port provided") + else: + self.io_bcr = KeyenceBarcodeScannerBackend(serial_port=barcode_port) - await self.io_bcr.stop() - raise TimeoutError(f"Barcode reader did not respond with MOTORON within {self.start_timeout} seconds") + try: + await self.io_bcr.setup() + except Exception as e: + raise RuntimeError(f"Could not setup barcode reader on {barcode_port}: {e}") async def stop_bcr(self): await self.io_bcr.stop() From 1d6250d1fc4d7b30e01f0b941af8cca8c61e6a14 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Fri, 16 Jan 2026 14:59:11 +0100 Subject: [PATCH 07/35] Combined barcode backend with liconic backend (optional) --- pylabrobot/storage/liconic/liconic_backend.py | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 37128e7c35d..f6785cb139e 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -44,11 +44,16 @@ def __init__(self, port: str, barcode_installed: Optional[bool] = None, barcode_ rtscts=True, ) + if barcode_installed: + if not barcode_port: + raise ValueError("barcode_port must also be provided if barcode is installed") + self.io_bcr = KeyenceBarcodeScannerBackend(serial_port=barcode_port) + self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None # Function to setup serial connection with Liconic PLC - async def setup(self) -> Serial: + async def setup(self): """ 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. 2. Send >200 ms break, wait 150 ms, flush buffers. @@ -87,36 +92,23 @@ async def setup(self) -> Serial: await self.io_plc.write(b"RD 1915\r") flag = await self.io_plc.readline() if flag.strip() == b"1": - return self.io_plc + break await asyncio.sleep(self.poll_interval) + else: + await self.io_plc.stop() + raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") - await self.io_plc.stop() - raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") + if self.io_bcr is not None: + try: + await self.io_bcr.setup() + except Exception as e: + await self.io_bcr.stop() + raise RuntimeError(f"Could not setup barcode reader on {self.barcode_port}: {e}") async def stop(self): await self.io_plc.stop() - - async def setup_bcr(self, barcode_installed: bool, barcode_port: str) -> KeyenceBarcodeScannerBackend: - """ - Setup barcode reader serial connection. - Liconic uses the Keyence BL-1300 barcode reader in older systems and BL-600HA in newer systems. - 1. Open serial port (9600 7E1, RTS/CTS) via the Serial wrapper. - """ - - if not barcode_installed: - raise RuntimeError("Liconic instance initialized with barcode scanner as false") - elif barcode_port is None: - raise RuntimeError("Liconic instance initialized with barcode scanner but no port provided") - else: - self.io_bcr = KeyenceBarcodeScannerBackend(serial_port=barcode_port) - - try: - await self.io_bcr.setup() - except Exception as e: - raise RuntimeError(f"Could not setup barcode reader on {barcode_port}: {e}") - - async def stop_bcr(self): - await self.io_bcr.stop() + if self.io_bcr is not None: + await self.io_bcr.stop() async def set_racks(self, racks: List[PlateCarrier]): await super().set_racks(racks) From 3bc0dfba37371484a02e7343705d1e67a5dc211b Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Fri, 16 Jan 2026 15:33:21 +0100 Subject: [PATCH 08/35] Added scan barcode to incubator front end --- pylabrobot/storage/backend.py | 5 +++++ pylabrobot/storage/incubator.py | 3 +++ pylabrobot/storage/liconic/liconic_backend.py | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py index 82af1917e04..c8e0831c8b8 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/storage/backend.py @@ -50,3 +50,8 @@ async def start_shaking(self, frequency: float): @abstractmethod async def stop_shaking(self): pass + + @abstractmethod + async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): + """Scan barcode at given position with specified pitch and timeout.""" + pass \ No newline at end of file diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 6ed68482173..a14fe794ee8 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -151,6 +151,9 @@ async def start_shaking(self, frequency: float = 1.0): async def stop_shaking(self): await self.backend.stop_shaking() + async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): + await self.backend.scan_barcode(cassette=m, position=n, pitch=pitch, plate_count=plt_count) + def summary(self) -> str: def create_pretty_table(header, *columns) -> str: col_widths = [ diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index f6785cb139e..10dde70d447 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -205,8 +205,8 @@ async def _send_command_bcr(self, command: str) -> str: """ cmd = command.strip() + "\r" logger.debug(f"Sending command to Barcode Reader: {cmd!r}") - await self.io_bcr.write(cmd.encode(self.serial_message_encoding)) - resp = (await self.io_bcr.read(128)).decode(self.serial_message_encoding) + resp = await self.io_bcr.send_command(cmd) + #resp = (await self.io_bcr.read(128)).decode(self.serial_message_encoding) if not resp: raise RuntimeError(f"No response from Barcode Reader for command {command!r}") resp = resp.strip() @@ -262,3 +262,17 @@ async def stop_shaking(self): """ Stop shaking. Using command RS 1913 """ await self._send_command_plc("RS 1913") await self._wait_ready() + + async def scan_barcode(self, cassette: int, position: int, pitch: int, plate_count: int) -> str: + """ Scan a barcode using the internal barcode reader. Using command LON """ + if not self.barcode_installed: + raise RuntimeError("Barcode reader not installed in this incubator instance") + + await self._send_command_plc(f"WR DM0 {cassette}") # carousel number + await self._send_command_plc(f"WR DM23 {pitch}") # pitch of plate in mm + await self._send_command_plc(f"WR DM25 {plate_count}") # plate + await self._send_command_plc(f"WR DM5 {position}") # plate position in carousel + await self._send_command_plc("ST 1910") # move shovel to barcode reading position + + barcode = await self._send_command_bcr("LON") + print(f"Scanned barcode: {barcode}") \ No newline at end of file From 1f5ae26fdffa65911669912cda12ac4b9ea24d14 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Mon, 19 Jan 2026 12:00:50 +0100 Subject: [PATCH 09/35] Added Liconic commands for front end and backend --- pylabrobot/storage/backend.py | 72 ++++++++++ pylabrobot/storage/incubator.py | 58 ++++++++ pylabrobot/storage/liconic/liconic_backend.py | 134 ++++++++++++++++++ 3 files changed, 264 insertions(+) diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py index 82af1917e04..611bd174a8f 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/storage/backend.py @@ -50,3 +50,75 @@ async def start_shaking(self, frequency: float): @abstractmethod async def stop_shaking(self): pass + + """ Methods added for Liconic incubator options.""" + + @abstractmethod + async def get_set_temperature(self) -> float: + """ Get the set value temperature of the incubator in degrees Celsius.""" + pass + + @abstractmethod + async def set_humidity(self, humidity: float): + """ Set operation humidity of the incubator in % RH; e.g. 90.0% RH.""" + pass + + @abstractmethod + async def get_humidity(self) -> float: + """ Get the current humidity of the incubator in % RH; e.g. 90.0% RH.""" + pass + + @abstractmethod + async def get_set_humidity(self) -> float: + """ Get the set value humidity of the incubator in % RH; e.g. 90.0% RH.""" + pass + + @abstractmethod + async def set_co2_level(self, co2_level: float): + """ Set operation CO2 level of the incubator in %; e.g. 5.0%.""" + pass + + @abstractmethod + async def get_co2_level(self) -> float: + """ Get the current CO2 level of the incubator in %; e.g. 5.0%.""" + pass + + @abstractmethod + async def get_set_co2_level(self) -> float: + """ Get the set value CO2 level of the incubator in %; e.g. 5.0%.""" + pass + + @abstractmethod + async def set_n2_level(self, n2_level: float): + """ Set operation N2 level of the incubator in %; e.g. 90.0%.""" + pass + + @abstractmethod + async def get_n2_level(self) -> float: + """ Get the current N2 level of the incubator in %; e.g. 90.0%.""" + pass + + @abstractmethod + async def get_set_n2_level(self) -> float: + """ Get the set value N2 level of the incubator in %; e.g. 90.0%.""" + pass + + @abstractmethod + async def turn_swap_station(self, home: bool): + """ Swap the incubator station to home or 180 degree position.""" + pass + + @abstractmethod + async def check_shovel_sensor(self) -> bool: + """ Check if there is a plate on the shovel plate sensor.""" + pass + + @abstractmethod + async def check_transfer_sensor(self) -> bool: + """ Check if there is a plate on the transfer sensor.""" + pass + + @abstractmethod + async def check_second_transfer_sensor(self) -> bool: + """ Check 2nd transfer station plate sensor.""" + pass diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 6ed68482173..ff9b40cd9fc 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -207,3 +207,61 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): category=data["category"], model=data["model"], ) + + """ Methods added for Liconic incubator options.""" + + async def get_set_temperature(self) -> float: + """ Get the set value temperature of the incubator in degrees Celsius.""" + return await self.backend.get_set_temperature() + + async def set_humidity(self, humidity: float): + """ Set the humidity of the incubator in percentage (%).""" + return await self.backend.set_humidity(humidity) + + async def get_humidity(self) -> float: + """ Get the humidity of the incubator in percentage (%).""" + return await self.backend.get_humidity() + + async def get_set_humidity(self) -> float: + """ Get the set value humidity of the incubator in percentage (%).""" + return await self.backend.get_set_humidity() + + async def set_co2_level(self, co2_level: float): + """ Set the CO2 level of the incubator in percentage (%).""" + return await self.backend.set_co2_level(co2_level) + + async def get_co2_level(self) -> float: + """ Get the CO2 level of the incubator in percentage (%).""" + return await self.backend.get_co2_level() + + async def get_set_co2_level(self) -> float: + """ Get the set value CO2 level of the incubator in percentage (%).""" + return await self.backend.get_set_co2_level() + + async def set_n2_level(self, n2_level: float): + """ Set the N2 level of the incubator in percentage (%).""" + return await self.backend.set_n2_level(n2_level) + + async def get_n2_level(self) -> float: + """ Get the N2 level of the incubator in percentage (%).""" + return await self.backend.get_n2_level() + + async def get_set_n2_level(self) -> float: + """ Get the set value N2 level of the incubator in percentage (%).""" + return await self.backend.get_set_n2_level() + + async def turn_swap_station(self, home: bool): + """ Turn the swap station of the incubator. If home is True, turn to home position.""" + return await self.backend.turn_swap_station(home) + + async def check_shovel_sensor(self) -> bool: + """ Check if the shovel plate sensor is activated.""" + return await self.backend.check_shovel_sensor() + + async def check_transfer_sensor(self) -> bool: + """ Check if the transfer plate sensor is activated.""" + return await self.backend.check_transfer_sensor() + + async def check_second_transfer_sensor(self) -> bool: + """ Check if the second transfer plate sensor is activated.""" + return await self.backend.check_second_transfer_sensor() diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index f6785cb139e..d1a273156a4 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -262,3 +262,137 @@ async def stop_shaking(self): """ Stop shaking. Using command RS 1913 """ await self._send_command_plc("RS 1913") await self._wait_ready() + + async def get_set_temperature(self) -> float: + """ Get the set value temperature of the incubator in degrees Celsius.""" + resp = await self._send_command_plc("RD DM890") + try: + temp_value = int(resp) + temperature = temp_value / 10.0 + return temperature + except ValueError: + raise RuntimeError(f"Invalid set temperature value received from incubator: {resp!r}") + + async def set_humidity(self, humidity: float): + """ Set the humidity of the incubator in percentage (%).""" + humidity_val = int(humidity * 10) + await self._send_command_plc(f"WR DM893 {str(humidity_val).zfill(5)}") + await self._wait_ready() + + async def get_humidity(self) -> float: + """ Get the actual humidity of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM983") + try: + humidity_value = int(resp) + humidity = humidity_value / 10.0 + return humidity + except ValueError: + raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") + + async def get_set_humidity(self) -> float: + """ Get the set value humidity of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM893") + try: + humidity_value = int(resp) + humidity = humidity_value / 10.0 + return humidity + except ValueError: + raise RuntimeError(f"Invalid set humidity value received from incubator: {resp!r}") + + async def set_co2_level(self, co2_level: float): + """ Set the CO2 level of the incubator in 1/100% vol. percentage (%) 500 = 5.0 % .""" + co2_val = int(co2_level * 100) + await self._send_command_plc(f"WR DM894 {str(co2_val).zfill(5)}") + await self._wait_ready() + + async def get_co2_level(self) -> float: + """ Get the CO2 level of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM984") + try: + co2_value = int(resp) + co2 = co2_value / 100.0 + return co2 + except ValueError: + raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") + + async def get_set_co2_level(self) -> float: + """ Get the set value CO2 level of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM894") + try: + co2_set_value = int(resp) + co2 = co2_set_value / 100.0 + return co2 + except ValueError: + raise RuntimeError(f"Invalid co2 set value received from incubator: {resp!r}") + + async def set_n2_level(self, n2_level: float): + """ Set the N2 level of the incubator in percentage (%).""" + n2_val = int(n2_level * 100) + await self._send_command_plc(f"WR DM895 {str(n2_val).zfill(5)}") + + async def get_n2_level(self) -> float: + """ Get the N2 level of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM985") + try: + n2_value = int(resp) + n2 = n2_value / 100.0 + return n2 + except ValueError: + raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") + + async def get_set_n2_level(self) -> float: + """ Get the set value N2 level of the incubator in percentage (%).""" + resp = await self._send_command_plc("RD DM895") + try: + n2_set_value = int(resp) + n2 = n2_set_value / 100.0 + return n2 + except ValueError: + raise RuntimeError(f"Invalid N2 set value received from incubator: {resp!r}") + + # UNTESTED + # Unsure what RD 1912 returns (is 1 home or swapped?) + async def turn_swap_station(self, home: bool): + """ Turn the swap station of the incubator. If home is True, turn to home position.""" + resp = await self._send_command_plc("RD 1912") + if home and resp == "1": + await self._send_command_plc("RS 1912") + else: + await self._send_command_plc("ST 1912") + + # UNTESTED + # Used in HT units only + async def check_shovel_sensor(self) -> bool: + """ First need to activate shovel transfer sensor deactivated by default, wait 0.1 seconds + and then Check if the shovel plate sensor is activated.""" + await self._send_command_plc("ST 1911") + asyncio.sleep(0.1) + resp = await self._send_command_plc("RD 1812") + if resp == "1": + return True + elif resp == "0": + return False + else: + raise RuntimeError(f"Unexpected response from incubator read shovel sensor: {resp!r}") + + # UNTESTED + async def check_transfer_sensor(self) -> bool: + """ Check if the transfer plate sensor is activated.""" + resp = await self._send_command_plc("RD 1813") + if resp == "1": + return True + elif resp == "0": + return False + else: + raise RuntimeError(f"Unexpected response from read transfer station sensor: {resp!r}") + + # UNTESTED + async def check_second_transfer_sensor(self) -> bool: + """ Check if the second transfer plate sensor is activated.""" + resp = await self._send_command_plc("RD 1807") + if resp == "1": + return True + elif resp == "0": + return False + else: + raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") From 9335d50383b67abbaae7516135d0fc3ee71634bd Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 21 Jan 2026 19:13:14 +0100 Subject: [PATCH 10/35] More backend functions --- pylabrobot/storage/liconic/liconic_backend.py | 113 +++++++++++++++--- 1 file changed, 95 insertions(+), 18 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index db524b56089..27b82d7fcd2 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -158,30 +158,67 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False): """ Take in a plate from the loading tray to the incubator.""" - m, n = self._site_to_m_n(site) - await self._send_command_plc(f"WR DM0 {m}") # carousel number - await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel + m, n = self._site_to_m_n(site) #Where is this supposed to come from?? + await self._send_command_plc(f"WR DM0 {m}") # cassette number + await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette await self._send_command_plc("ST 1904") # plate from transfer station await self._wait_ready() - if self.barcode_installed and read_barcode: + if read_barcode: + await self.read_barcode_inline(m,n) + + await self._send_command_plc("ST 1903") # terminate access + + async def move_position_to_position(self, + plate: Plate, + orig_site: PlateHolder, + dest_site: PlateHolder, + read_barcode: Optional[bool]=False): + """ Move plate from one internal position to another""" + orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # + dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # + + await self._send_command_plc(f"WR DM 0 {orig_m}") # origin cassette # + await self._send_command_plc(f"WR DM 5 {orig_n}") # origin plate position # + + if read_barcode: + await self.read_barcode_inline(orig_m,orig_n) + + await self._send_command_plc("ST 1908") # pick plate from origin position + + await self._wait_ready() + + if orig_m != dest_m: + await self._send_command_plc(f"WR DM0 {dest_m}") # destination cassette # if different + await self._send_command_plc(f"WR DM5 {dest_n}") # destination plate position # + await self._send_command_plc("ST 1909") # place plate in destination position + + await self._wait_ready() + await self._send_command_plc("ST 1903") # terminate access + + async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: + if self.barcode_installed: await self._send_command_plc("ST 1910") # move shovel to barcode reading position await self._wait_ready() barcode = await self._send_command_bcr("LON") # read barcode if barcode is None: raise RuntimeError("Failed to read barcode from plate") elif barcode == "ERROR": - logger.info(f"No barcode found when reading plate at cassette {m}, position {n}") + logger.info(f"No barcode found when reading plate at cassette {cassette}, position {plt_position}") else: - logger.info(f"Read barcode from plate at cassette {m}, position {n}: {barcode}") + logger.info(f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode}") reset = await self._send_command_plc("RS 1910") # move shovel back to normal position - if reset != "OK": - raise RuntimeError("Failed to reset shovel position after barcode reading") + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") await self._wait_ready() - elif read_barcode and not self.barcode_installed: - logger.info(" Barcode reading requested during import but instance not configured with barcode reader.") + return barcode + else: + logger.info(" Barcode reading requested but instance not configured with barcode reader.") + return "No barcode" + + + async def scan_cassette(self,): - await self._send_command_plc("ST 1903") # terminate access async def _send_command_plc(self, command: str) -> str: """ @@ -216,6 +253,19 @@ async def _send_command_bcr(self, command: str) -> str: raise RuntimeError(f"Error response from Barcode Reader for command {command!r}: {resp!r}") return resp + async def _wait_plate_ready(self, timeout: int = 60): + """ + Poll the plate-ready flag (RD 1914) until it is set, or timeout is reached. + """ + start = time.time() + deadline = start + timeout + while time.time() < deadline: + resp = await self._send_command_plc("RD 1914") + if resp == "1": + return + await asyncio.sleep(0.1) + raise TimeoutError(f"Plate did not become ready within {timeout} seconds") + async def _wait_ready(self, timeout: int = 60): """ Poll the ready-flag (RD 1915) until it is set, or timeout is reached. @@ -247,17 +297,39 @@ async def get_temperature(self) -> float: except ValueError: raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") - async def start_shaking(self, frequency: float = 10.0): - """ Start shaking. Frequency by default is 10 Hz. Using command ST 1913. This functionality is - not currently able to be tested. """ + # UNTESTED + # Unsure if 1 means ON and 0 means OFF, needs to be confirmed. + async def shaker_status(self) -> int: + """ Determines whether the shaker is ON (1) or OFF (0)""" + value = await self._send_command_plc() + await self._wait_ready() + return value + + # UNTESTED + # Unsure if a liconic will return 00250 for 25 or 00025. Assuming former. + # Should be in Hz + async def get_shaker_speed(self) -> float: + """ Gets the current shaker speed default = 25""" + speed_val = await self._send_command_plc("RD DM39") + speed = speed_val / 10.0 + await self._wait_ready() + return speed + + # UNTESTED + # Unsure if setting WR DM39 00250 will set it at 25 Hz or if WR DM39 00025 will. Assuming former + async def start_shaking(self, frequency): + """ Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Using command + ST 1913. This functionality is not currently able to be tested. """ if frequency < 1.0 or frequency > 50.0: raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") else: frequency_value = int(frequency) # assuming incubator expects frequency in 0.1 Hz units - await self._send_command_plc(f"WR DM39 {frequency_value}") + frequency = frequency_value * 10 + await self._send_command_plc(f"WR DM39 {str(frequency).zfill(5)}") await self._send_command_plc("ST 1913") await self._wait_ready() + # UNTESTED async def stop_shaking(self): """ Stop shaking. Using command RS 1913 """ await self._send_command_plc("RS 1913") @@ -299,12 +371,14 @@ async def get_set_humidity(self) -> float: except ValueError: raise RuntimeError(f"Invalid set humidity value received from incubator: {resp!r}") + # UNTESTED async def set_co2_level(self, co2_level: float): """ Set the CO2 level of the incubator in 1/100% vol. percentage (%) 500 = 5.0 % .""" co2_val = int(co2_level * 100) await self._send_command_plc(f"WR DM894 {str(co2_val).zfill(5)}") await self._wait_ready() + # UNTESTED async def get_co2_level(self) -> float: """ Get the CO2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM984") @@ -314,7 +388,7 @@ async def get_co2_level(self) -> float: return co2 except ValueError: raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") - + # UNTESTED async def get_set_co2_level(self) -> float: """ Get the set value CO2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM894") @@ -330,6 +404,7 @@ async def set_n2_level(self, n2_level: float): n2_val = int(n2_level * 100) await self._send_command_plc(f"WR DM895 {str(n2_val).zfill(5)}") + # UNTESTED async def get_n2_level(self) -> float: """ Get the N2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM985") @@ -340,6 +415,7 @@ async def get_n2_level(self) -> float: except ValueError: raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") + # UNTESTED async def get_set_n2_level(self) -> float: """ Get the set value N2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM895") @@ -352,6 +428,7 @@ async def get_set_n2_level(self) -> float: # UNTESTED # Unsure what RD 1912 returns (is 1 home or swapped?) + # Another avenue is to read the first byte of T16 or T17 but don't have ability to test async def turn_swap_station(self, home: bool): """ Turn the swap station of the incubator. If home is True, turn to home position.""" resp = await self._send_command_plc("RD 1912") @@ -361,7 +438,7 @@ async def turn_swap_station(self, home: bool): await self._send_command_plc("ST 1912") # UNTESTED - # Used in HT units only + # Activate plate sensor (ST 1911) used in HT units only because it is off by default async def check_shovel_sensor(self) -> bool: """ First need to activate shovel transfer sensor deactivated by default, wait 0.1 seconds and then Check if the shovel plate sensor is activated.""" @@ -409,4 +486,4 @@ async def scan_barcode(self, cassette: int, position: int, pitch: int, plate_cou await self._send_command_plc("ST 1910") # move shovel to barcode reading position barcode = await self._send_command_bcr("LON") - print(f"Scanned barcode: {barcode}") \ No newline at end of file + print(f"Scanned barcode: {barcode}") From f66dbad674fe1d4813aa18a520d3276bf9226e6f Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Thu, 22 Jan 2026 15:24:40 +0100 Subject: [PATCH 11/35] add plate storage functionality --- pylabrobot/storage/liconic/constants.py | 62 ++++++++ pylabrobot/storage/liconic/liconic_backend.py | 64 +++++--- pylabrobot/storage/liconic/racks.py | 147 ++++++++++++++++++ 3 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 pylabrobot/storage/liconic/constants.py create mode 100644 pylabrobot/storage/liconic/racks.py diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py new file mode 100644 index 00000000000..17aeceb3e91 --- /dev/null +++ b/pylabrobot/storage/liconic/constants.py @@ -0,0 +1,62 @@ +from enum import Enum, IntEnum + +class LiconicType(Enum): + STX44_IC = "IC" # incubator + STX44_HC = "HC" # humid cooler + STX44_DC2 = "DC2" # dry storage + STX44_HR = "HR" # humid wide range + STX44_DR2 = "DR2" # dry wide range + STX44_AR = "AR" # humidity controlled + STX44_DF = "DF" # deep freezer + STX44_NC = "NC" # no climate + STX44_DH = "DH" # dry humid + + STX110_IC = "STX110_IC" # incubator + STX110_HC = "STX110_HC" # humid cooler + STX110_DC2 = "STX110_DC2" # dry storage + STX110_HR = "STX110_HR" # humid wide range + STX110_DR2 = "STX110_DR2" # dry wide range + STX110_AR = "STX110_AR" # humidity controlled + STX110_DF = "STX110_DF" # deep freezer + STX110_NC = "STX110_NC" # no climate + STX110_DH = "STX110_DH" # dry humid + + STX220_IC = "STX220_IC" # incubator + STX220_HC = "STX220_HC" # humid cooler + STX220_DC2 = "STX220_DC2" # dry storage + STX220_HR = "STX220_HR" # humid wide range + STX220_DR2 = "STX220_DR2" # dry wide range + STX220_AR = "STX220_AR" # humidity controlled + STX220_DF = "STX220_DF" # deep freezer + STX220_NC = "STX220_NC" # no climate + STX220_DH = "STX220_DH" # dry humid + + STX280_IC = "STX280_IC" # incubator + STX280_HC = "STX280_HC" # humid cooler + STX280_DC2 = "STX280_DC2" # dry storage + STX280_HR = "STX280_HR" # humid wide range + STX280_DR2 = "STX280_DR2" # dry wide range + STX280_AR = "STX280_AR" # humidity controlled + STX280_DF = "STX280_DF" # deep freezer + STX280_NC = "STX280_NC" # no climate + STX280_DH = "STX44_DH" # dry humid + + STX500_IC = "STX500_IC" # incubator + STX500_HC = "STX500_HC" # humid cooler + STX500_DC2 = "STX500_DC2" # dry storage + STX500_HR = "STX500_HR" # humid wide range + STX500_DR2 = "STX500_DR2" # dry wide range + STX500_AR = "STX500_AR" # humidity controlled + STX500_DF = "STX500_DF" # deep freezer + STX500_NC = "STX500_NC" # no climate + STX500_DH = "STX500_DH" # dry humid + + STX1000_IC = "STX1000_IC" # incubator + STX1000_HC = "STX1000_HC" # humid cooler + STX1000_DC2 = "STX1000_DC2" # dry storage + STX1000_HR = "STX1000_HR" # humid wide range + STX1000_DR2 = "STX1000_DR2" # dry wide range + STX1000_AR = "STX1000_AR" # humidity controlled + STX1000_DF = "STX1000_DF" # deep freezer + STX1000_NC = "STX1000_NC" # no climate + STX1000_DH = "STX1000_DH" # dry humid diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 27b82d7fcd2..e77ca30a8bd 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -2,7 +2,7 @@ import logging import time import warnings -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, Union import serial @@ -11,6 +11,7 @@ from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend +from pylabrobot.storage.liconic.constants import LiconicType logger = logging.getLogger(__name__) @@ -27,12 +28,21 @@ class LiconicBackend(IncubatorBackend): start_timeout = 15.0 poll_interval = 0.2 - def __init__(self, port: str, barcode_installed: Optional[bool] = None, barcode_port: Optional[str] = None): + def __init__(self, model: Union[LiconicType, str], port: str, barcode_installed: Optional[bool] = None, barcode_port: Optional[str] = None): super().__init__() self.barcode_installed: Optional[bool] = barcode_installed self.barcode_port: Optional[str] = barcode_port + if isinstance(model, str): + try: + model = LiconicType(model) + except ValueError: + raise ValueError(f"Unsupported Liconic model: '{model}") + + self.model = model + self._racks: List[PlateCarrier] = [] + self.io_plc = Serial( port=port, baudrate=self.default_baud, @@ -105,6 +115,14 @@ async def setup(self): await self.io_bcr.stop() raise RuntimeError(f"Could not setup barcode reader on {self.barcode_port}: {e}") + def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, cytomat is 1-indexed + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed + return rack_idx, site_idx + async def stop(self): await self.io_plc.stop() if self.io_bcr is not None: @@ -135,22 +153,8 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read await self._send_command_plc(f"WR DM0 {m}") # carousel number await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel - if self.barcode_installed and read_barcode: - await self._send_command_plc("ST 1910") # move shovel to barcode reading position - await self._wait_ready() - barcode = await self._send_command_bcr("LON") # read barcode, need to check if this needs a timeout level signal trigger vs. one-shot read - if barcode is None: - raise RuntimeError("Failed to read barcode from plate") - elif barcode == "ERROR": - logger.info(f"No barcode found when reading plate at cassette {m}, position {n}") - else: - logger.info(f"Read barcode from plate at cassette {m}, position {n}: {barcode}") - reset = await self._send_command_plc("RS 1910") # move shovel back to normal position - if reset != "OK": - raise RuntimeError("Failed to reset shovel position after barcode reading") - await self._wait_ready() - elif read_barcode and not self.barcode_installed: - logger.info(" Barcode reading requested during export but instance not configured with barcode reader.") + if read_barcode: + await self.read_barcode_inline(m,n) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() @@ -158,7 +162,7 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False): """ Take in a plate from the loading tray to the incubator.""" - m, n = self._site_to_m_n(site) #Where is this supposed to come from?? + m, n = self._site_to_m_n(site) await self._send_command_plc(f"WR DM0 {m}") # cassette number await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette await self._send_command_plc("ST 1904") # plate from transfer station @@ -218,7 +222,7 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: async def scan_cassette(self,): - + pass async def _send_command_plc(self, command: str) -> str: """ @@ -282,6 +286,9 @@ async def _wait_ready(self, timeout: int = 60): async def set_temperature(self, temperature: float): """ Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370) """ + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + temp_value = int(temperature * 10) temp_str = str(temp_value).zfill(5) await self._send_command_plc(f"WR DM890 {temp_str}") @@ -289,6 +296,9 @@ async def set_temperature(self, temperature: float): async def get_temperature(self) -> float: """ Get the temperature of the incubator in degrees Celsius. Using command RD DM982 """ + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command_plc("RD DM982") try: temp_value = int(resp) @@ -337,6 +347,9 @@ async def stop_shaking(self): async def get_set_temperature(self) -> float: """ Get the set value temperature of the incubator in degrees Celsius.""" + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command_plc("RD DM890") try: temp_value = int(resp) @@ -347,12 +360,18 @@ async def get_set_temperature(self) -> float: async def set_humidity(self, humidity: float): """ Set the humidity of the incubator in percentage (%).""" + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + humidity_val = int(humidity * 10) await self._send_command_plc(f"WR DM893 {str(humidity_val).zfill(5)}") await self._wait_ready() async def get_humidity(self) -> float: """ Get the actual humidity of the incubator in percentage (%).""" + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command_plc("RD DM983") try: humidity_value = int(resp) @@ -363,6 +382,9 @@ async def get_humidity(self) -> float: async def get_set_humidity(self) -> float: """ Get the set value humidity of the incubator in percentage (%).""" + if self.model.value.split('_')[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command_plc("RD DM893") try: humidity_value = int(resp) @@ -388,6 +410,7 @@ async def get_co2_level(self) -> float: return co2 except ValueError: raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") + # UNTESTED async def get_set_co2_level(self) -> float: """ Get the set value CO2 level of the incubator in percentage (%).""" @@ -399,6 +422,7 @@ async def get_set_co2_level(self) -> float: except ValueError: raise RuntimeError(f"Invalid co2 set value received from incubator: {resp!r}") + # UNTESTED async def set_n2_level(self, n2_level: float): """ Set the N2 level of the incubator in percentage (%).""" n2_val = int(n2_level * 100) diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py new file mode 100644 index 00000000000..cae39550d44 --- /dev/null +++ b/pylabrobot/storage/liconic/racks.py @@ -0,0 +1,147 @@ +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateCarrier, PlateHolder +from typing import Optional + +def _liconic_rack(name: str, + pitch: int, + site_height: int, + num_sites: int, + model: str, + total_height: Optional[int] = 505, # 645 and 1210 for STX 500 and STX1000 only + bicarousel: Optional[bool] = False # for STX500 and STX1000 only + ): + start = 17.2 # rough height of first plate position + pitch=pitch, + bicarousel = bicarousel, + return PlateCarrier( + name=name, + size_x=109, # based off cytomat rack dimensions roughly the same + size_y=142, + size_z= total_height, + sites={ + i: PlateHolder( + size_x=85.48, + size_y=127.27, + # estimates + size_z=max(site_height, total_height - site_height) if i == num_sites - 1 else site_height, + name=f"{name} - {i + 1}", + pedestal_size_z=0, + ).at( + Coordinate( + x=11.76, #estimate + y=0, + z=start + site_height * i, + ) + ) + for i in range(num_sites) + }, + model=model, + ) + +def liconic_rack_5mm_42(name: str): + return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=42, model="liconic_rack_5mm_42") + +def liconic_rack_5mm_55(name: str): + return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=55, model="liconic_rack_5mm_55", total_height=645, bicarousel=True) + +def liconic_rack_5mm_111(name: str): + return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=111, model="liconic_rack_5mm_111", total_height=1210, bicarousel=True) + +def liconic_rack_11mm_28(name: str): + return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=28, model="liconic_rack_5mm_28") + +def liconic_rack_11mm_37(name: str): + return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=37, model="liconic_rack_5mm_37", total_height=645, bicarousel=True) + +def liconic_rack_11mm_72(name: str): + return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=72, model="liconic_rack_5mm_72", total_height=1210, bicarousel=True) + +def liconic_rack_12mm_27(name: str): + return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=27, model="liconic_rack_5mm_27") + +def liconic_rack_12mm_35(name: str): + return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=35, model="liconic_rack_5mm_35", total_height=645, bicarousel=True) + +def liconic_rack_12mm_68(name: str): + return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=68, model="liconic_rack_5mm_68", total_height=1210, bicarousel=True) + +def liconic_rack_17mm_22(name: str): + return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=22, model="liconic_rack_5mm_22") + +def liconic_rack_17mm_28(name: str): + return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=28, model="liconic_rack_5mm_28", total_height=645, bicarousel=True) + +def liconic_rack_17mm_53(name: str): + return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=53, model="liconic_rack_5mm_53", total_height=1210, bicarousel=True) + +def liconic_rack_22mm_17(name: str): + return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=17, model="liconic_rack_22mm_17") + +def liconic_rack_22mm_23(name: str): + return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=23, model="liconic_rack_22mm_23", total_height=645, bicarousel=True) + +def liconic_rack_22mm_43(name: str): + return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=43, model="liconic_rack_22mm_43", total_height=1210, bicarousel=True) + +def liconic_rack_23mm_17(name: str): + return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=17, model="liconic_rack_23mm_17") + +def liconic_rack_23mm_22(name: str): + return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=22, model="liconic_rack_23mm_22", total_height=645, bicarousel=True) + +def liconic_rack_23mm_42(name: str): + return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=42, model="liconic_rack_23mm_42", total_height=1210, bicarousel=True) + +def liconic_rack_24mm_17(name: str): + return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=17, model="liconic_rack_24mm_17") + +def liconic_rack_24mm_21(name: str): + return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=21, model="liconic_rack_24mm_21", total_height=645, bicarousel=True) + +def liconic_rack_24mm_41(name: str): + return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=41, model="liconic_rack_24mm_41", total_height=1210, bicarousel=True) + +def liconic_rack_27mm_15(name: str): + return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=15, model="liconic_rack_27mm_15") + +def liconic_rack_27mm_19(name: str): + return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=19, model="liconic_rack_27mm_19", total_height=645, bicarousel=True) + +def liconic_rack_27mm_37(name: str): + return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=37, model="liconic_rack_27mm_37", total_height=1210, bicarousel=True) + +def liconic_rack_44mm_10(name: str): + return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=10, model="liconic_rack_44mm_10") + +def liconic_rack_44mm_13(name: str): + return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=13, model="liconic_rack_44mm_13", total_height=645, bicarousel=True) + +def liconic_rack_44mm_25(name: str): + return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=25, model="liconic_rack_44mm_25", total_height=1210, bicarousel=True) + +def liconic_rack_53mm_8(name: str): + return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=8, model="liconic_rack_53mm_8") + +def liconic_rack_53mm_10(name: str): + return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=10, model="liconic_rack_53mm_10", total_height=645, bicarousel=True) + +def liconic_rack_53mm_21(name: str): + return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=21, model="liconic_rack_53mm_21", total_height=1210, bicarousel=True) + +def liconic_rack_66mm_7(name: str): + return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=7, model="liconic_rack_66mm_7") + +def liconic_rack_66mm_8(name: str): + return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=8, model="liconic_rack_66mm_8", total_height=645, bicarousel=True) + +def liconic_rack_66mm_17(name: str): + return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=17, model="liconic_rack_66mm_17", total_height=1210, bicarousel=True) + +def liconic_rack_104mm_4(name: str): + return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=4, model="liconic_rack_104mm_4") + +def liconic_rack_104mm_5(name: str): + return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=5, model="liconic_rack_104mm_5", total_height=645, bicarousel=True) + +def liconic_rack_104mm_11(name: str): + return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=11, model="liconic_rack_104mm_11", total_height=1210, bicarousel=True) From 3098843e03e38676384594db52db822661df0214 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Thu, 22 Jan 2026 16:17:10 +0100 Subject: [PATCH 12/35] test plate fetch --- pylabrobot/storage/liconic/liconic_backend.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index e77ca30a8bd..6133548d7d5 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -2,7 +2,7 @@ import logging import time import warnings -from typing import List, Tuple, Optional, Union +from typing import List, Tuple, Optional, Union, cast import serial @@ -119,7 +119,7 @@ def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: rack = site.parent assert isinstance(rack, PlateCarrier), "Site not in rack" assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, cytomat is 1-indexed + rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, liconic is 1-indexed site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed return rack_idx, site_idx @@ -164,7 +164,8 @@ async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Opt """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) await self._send_command_plc(f"WR DM0 {m}") # cassette number - await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette + await self._send_command_plc(f"WR DM23 788") + await self._send_command_plc(f"WR DM25 10") # plate position in cassette await self._send_command_plc("ST 1904") # plate from transfer station await self._wait_ready() From 1ad06c2342055290e5ef2dba829155b2520c6674 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Thu, 22 Jan 2026 18:01:52 +0100 Subject: [PATCH 13/35] Motor step retrieval completed --- pylabrobot/storage/liconic/constants.py | 18 ++--- pylabrobot/storage/liconic/liconic_backend.py | 51 +++++++++++- pylabrobot/storage/liconic/racks.py | 80 ++++++++++--------- 3 files changed, 103 insertions(+), 46 deletions(-) diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py index 17aeceb3e91..dbc9e86d4d2 100644 --- a/pylabrobot/storage/liconic/constants.py +++ b/pylabrobot/storage/liconic/constants.py @@ -1,15 +1,15 @@ from enum import Enum, IntEnum class LiconicType(Enum): - STX44_IC = "IC" # incubator - STX44_HC = "HC" # humid cooler - STX44_DC2 = "DC2" # dry storage - STX44_HR = "HR" # humid wide range - STX44_DR2 = "DR2" # dry wide range - STX44_AR = "AR" # humidity controlled - STX44_DF = "DF" # deep freezer - STX44_NC = "NC" # no climate - STX44_DH = "DH" # dry humid + STX44_IC = "STX44_IC" # incubator + STX44_HC = "STX44_HC" # humid cooler + STX44_DC2 = "STX44_DC2" # dry storage + STX44_HR = "STX44_HR" # humid wide range + STX44_DR2 = "STX44_DR2" # dry wide range + STX44_AR = "STX44_AR" # humidity controlled + STX44_DF = "STX44_DF" # deep freezer + STX44_NC = "STX44_NC" # no climate + STX44_DH = "STX44_DH" # dry humid STX110_IC = "STX110_IC" # incubator STX110_HC = "STX110_HC" # humid cooler diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index e77ca30a8bd..28a1f167d92 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -2,6 +2,7 @@ import logging import time import warnings +import re from typing import List, Tuple, Optional, Union import serial @@ -15,6 +16,23 @@ logger = logging.getLogger(__name__) +# Mapping site_height to motor steps for Liconic cassettes +LICONIC_SITE_HEIGHT_TO_STEPS = { + 5: 377, # pitch=11, site_height=5 + 11: 582, # pitch=17, site_height=11 + 12: 617, # pitch=18, site_height=12 + 17: 788, # pitch=23, site_height=17 + 22: 959, # pitch=28, site_height=22 + 23: 994, # pitch=29, site_height=23 + 24: 1028, # pitch=30, site_height=24 + 27: 1131, # pitch=33, site_height=27 + 44: 1713, # pitch=50, site_height=44 + 53: 2021, # pitch=59, site_height=53 + 66: 2467, # pitch=72, site_height=66 + 104: 3563 # pitch=110, site_height=104 +} + + class LiconicBackend(IncubatorBackend): """ Backend for Liconic incubators. @@ -123,6 +141,20 @@ def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed return rack_idx, site_idx + # Wrote this function to return motor step size and plate position number from PlateCarrier model name + def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + if not rack.model.startswith("liconic"): + raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") + match = re.search(r'_(\d+)mm', rack.model) + if match: + site_height = int(match.group(1)) + site_num = int(rack.model.split('_')[-1]) + return LICONIC_SITE_HEIGHT_TO_STEPS.get(site_height), site_num + raise ValueError(f"Could not parse site height and pos num from PlateCarrier model: {rack.model}") + async def stop(self): await self.io_plc.stop() if self.io_bcr is not None: @@ -149,8 +181,13 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read """ Fetch a plate from the incubator to the loading tray.""" site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" + m, n = self._site_to_m_n(site) + step_size, pos_num = self._carrier_to_steps_pos(site) + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel if read_barcode: @@ -163,7 +200,11 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False): """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) - await self._send_command_plc(f"WR DM0 {m}") # cassette number + step_size, pos_num = self._carrier_to_steps_pos(site) + + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette await self._send_command_plc("ST 1904") # plate from transfer station await self._wait_ready() @@ -183,6 +224,12 @@ async def move_position_to_position(self, dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # await self._send_command_plc(f"WR DM 0 {orig_m}") # origin cassette # + orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) + dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) + + await self._send_command_plc(f"WR DM0 {orig_m}") # carousel number + await self._send_command_plc(f"WR DM23 {orig_step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {orig_pos_num}") # number of positions in cassette await self._send_command_plc(f"WR DM 5 {orig_n}") # origin plate position # if read_barcode: @@ -194,6 +241,8 @@ async def move_position_to_position(self, if orig_m != dest_m: await self._send_command_plc(f"WR DM0 {dest_m}") # destination cassette # if different + await self._send_command_plc(f"WR DM23 {dest_step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {dest_pos_num}") # number of positions in cassette await self._send_command_plc(f"WR DM5 {dest_n}") # destination plate position # await self._send_command_plc("ST 1909") # place plate in destination position diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py index cae39550d44..c93caf540ef 100644 --- a/pylabrobot/storage/liconic/racks.py +++ b/pylabrobot/storage/liconic/racks.py @@ -4,6 +4,7 @@ def _liconic_rack(name: str, pitch: int, + steps: int, site_height: int, num_sites: int, model: str, @@ -12,6 +13,7 @@ def _liconic_rack(name: str, ): start = 17.2 # rough height of first plate position pitch=pitch, + steps = steps, bicarousel = bicarousel, return PlateCarrier( name=name, @@ -38,110 +40,116 @@ def _liconic_rack(name: str, model=model, ) +""" The motor step size used to set DM23 in the Liconic is calculated using the known step size of + for 23mm pitch which is 788 and for 50 mm which is 1713. + + Therefore for the other pitch sizes: step size = pitch / (50 / 1713) and then rounded to nearest + whole number""" + def liconic_rack_5mm_42(name: str): - return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=42, model="liconic_rack_5mm_42") + return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=42, model="liconic_rack_5mm_42") def liconic_rack_5mm_55(name: str): - return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=55, model="liconic_rack_5mm_55", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=55, model="liconic_rack_5mm_55", total_height=645, bicarousel=True) def liconic_rack_5mm_111(name: str): - return _liconic_rack(name=name, pitch=11, site_height=5, num_sites=111, model="liconic_rack_5mm_111", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=111, model="liconic_rack_5mm_111", total_height=1210, bicarousel=True) def liconic_rack_11mm_28(name: str): - return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=28, model="liconic_rack_5mm_28") + return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=28, model="liconic_rack_11mm_28") def liconic_rack_11mm_37(name: str): - return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=37, model="liconic_rack_5mm_37", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=37, model="liconic_rack_11mm_37", total_height=645, bicarousel=True) def liconic_rack_11mm_72(name: str): - return _liconic_rack(name=name, pitch=17, site_height=11, num_sites=72, model="liconic_rack_5mm_72", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=72, model="liconic_rack_11mm_72", total_height=1210, bicarousel=True) def liconic_rack_12mm_27(name: str): - return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=27, model="liconic_rack_5mm_27") + return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=27, model="liconic_rack_12mm_27") def liconic_rack_12mm_35(name: str): - return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=35, model="liconic_rack_5mm_35", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=35, model="liconic_rack_12mm_35", total_height=645, bicarousel=True) def liconic_rack_12mm_68(name: str): - return _liconic_rack(name=name, pitch=18, site_height=12, num_sites=68, model="liconic_rack_5mm_68", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=68, model="liconic_rack_12mm_68", total_height=1210, bicarousel=True) def liconic_rack_17mm_22(name: str): - return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=22, model="liconic_rack_5mm_22") + return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=22, model="liconic_rack_17mm_22") def liconic_rack_17mm_28(name: str): - return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=28, model="liconic_rack_5mm_28", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=28, model="liconic_rack_17mm_28", total_height=645, bicarousel=True) def liconic_rack_17mm_53(name: str): - return _liconic_rack(name=name, pitch=23, site_height=17, num_sites=53, model="liconic_rack_5mm_53", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=53, model="liconic_rack_17mm_53", total_height=1210, bicarousel=True) def liconic_rack_22mm_17(name: str): - return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=17, model="liconic_rack_22mm_17") + return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=17, model="liconic_rack_22mm_17") def liconic_rack_22mm_23(name: str): - return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=23, model="liconic_rack_22mm_23", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=23, model="liconic_rack_22mm_23", total_height=645, bicarousel=True) def liconic_rack_22mm_43(name: str): - return _liconic_rack(name=name, pitch=28, site_height=22, num_sites=43, model="liconic_rack_22mm_43", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=43, model="liconic_rack_22mm_43", total_height=1210, bicarousel=True) def liconic_rack_23mm_17(name: str): - return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=17, model="liconic_rack_23mm_17") + return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=17, model="liconic_rack_23mm_17") def liconic_rack_23mm_22(name: str): - return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=22, model="liconic_rack_23mm_22", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=22, model="liconic_rack_23mm_22", total_height=645, bicarousel=True) def liconic_rack_23mm_42(name: str): - return _liconic_rack(name=name, pitch=29, site_height=23, num_sites=42, model="liconic_rack_23mm_42", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=42, model="liconic_rack_23mm_42", total_height=1210, bicarousel=True) def liconic_rack_24mm_17(name: str): - return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=17, model="liconic_rack_24mm_17") + return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=17, model="liconic_rack_24mm_17") def liconic_rack_24mm_21(name: str): - return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=21, model="liconic_rack_24mm_21", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=21, model="liconic_rack_24mm_21", total_height=645, bicarousel=True) def liconic_rack_24mm_41(name: str): - return _liconic_rack(name=name, pitch=30, site_height=24, num_sites=41, model="liconic_rack_24mm_41", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=41, model="liconic_rack_24mm_41", total_height=1210, bicarousel=True) def liconic_rack_27mm_15(name: str): - return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=15, model="liconic_rack_27mm_15") + return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=15, model="liconic_rack_27mm_15") def liconic_rack_27mm_19(name: str): - return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=19, model="liconic_rack_27mm_19", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=19, model="liconic_rack_27mm_19", total_height=645, bicarousel=True) def liconic_rack_27mm_37(name: str): - return _liconic_rack(name=name, pitch=33, site_height=27, num_sites=37, model="liconic_rack_27mm_37", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=37, model="liconic_rack_27mm_37", total_height=1210, bicarousel=True) def liconic_rack_44mm_10(name: str): - return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=10, model="liconic_rack_44mm_10") + return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=10, model="liconic_rack_44mm_10") def liconic_rack_44mm_13(name: str): - return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=13, model="liconic_rack_44mm_13", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=13, model="liconic_rack_44mm_13", total_height=645, bicarousel=True) def liconic_rack_44mm_25(name: str): - return _liconic_rack(name=name, pitch=50, site_height=44, num_sites=25, model="liconic_rack_44mm_25", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=25, model="liconic_rack_44mm_25", total_height=1210, bicarousel=True) def liconic_rack_53mm_8(name: str): - return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=8, model="liconic_rack_53mm_8") + return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=8, model="liconic_rack_53mm_8") def liconic_rack_53mm_10(name: str): - return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=10, model="liconic_rack_53mm_10", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=10, model="liconic_rack_53mm_10", total_height=645, bicarousel=True) def liconic_rack_53mm_21(name: str): - return _liconic_rack(name=name, pitch=59, site_height=53, num_sites=21, model="liconic_rack_53mm_21", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=21, model="liconic_rack_53mm_21", total_height=1210, bicarousel=True) def liconic_rack_66mm_7(name: str): - return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=7, model="liconic_rack_66mm_7") + return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=7, model="liconic_rack_66mm_7") def liconic_rack_66mm_8(name: str): - return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=8, model="liconic_rack_66mm_8", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=8, model="liconic_rack_66mm_8", total_height=645, bicarousel=True) def liconic_rack_66mm_17(name: str): - return _liconic_rack(name=name, pitch=72, site_height=66, num_sites=17, model="liconic_rack_66mm_17", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=17, model="liconic_rack_66mm_17", total_height=1210, bicarousel=True) def liconic_rack_104mm_4(name: str): - return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=4, model="liconic_rack_104mm_4") + return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=4, model="liconic_rack_104mm_4") def liconic_rack_104mm_5(name: str): - return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=5, model="liconic_rack_104mm_5", total_height=645, bicarousel=True) + return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=5, model="liconic_rack_104mm_5", total_height=645, bicarousel=True) def liconic_rack_104mm_11(name: str): - return _liconic_rack(name=name, pitch=110, site_height=104, num_sites=11, model="liconic_rack_104mm_11", total_height=1210, bicarousel=True) + return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=11, model="liconic_rack_104mm_11", total_height=1210, bicarousel=True) From 67cabb2e9b03e95cb6f5338e66321beeed960269 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Thu, 22 Jan 2026 18:44:04 +0100 Subject: [PATCH 14/35] Move position to position --- pylabrobot/storage/incubator.py | 5 +++++ pylabrobot/storage/liconic/liconic_backend.py | 15 +++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 95a856cb017..7e7c22d09b9 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -151,6 +151,7 @@ async def start_shaking(self, frequency: float = 1.0): async def stop_shaking(self): await self.backend.stop_shaking() + # REDO async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): await self.backend.scan_barcode(cassette=m, position=n, pitch=pitch, plate_count=plt_count) @@ -268,3 +269,7 @@ async def check_transfer_sensor(self) -> bool: async def check_second_transfer_sensor(self) -> bool: """ Check if the second transfer plate sensor is activated.""" return await self.backend.check_second_transfer_sensor() + + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): + """ Move a plate to another internal position in the storage unit """ + return await self.backend.move_position_to_position(plate=plate, dest_site=dest_site, read_barcode=read_barcode) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 5f2fd5cb248..da55272e74e 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -198,7 +198,7 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False): + async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=False): """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) await self._send_command_plc(f"WR DM0 {m}") # cassette number @@ -218,12 +218,15 @@ async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Opt await self._send_command_plc("ST 1903") # terminate access - async def move_position_to_position(self, - plate: Plate, - orig_site: PlateHolder, - dest_site: PlateHolder, - read_barcode: Optional[bool]=False): + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): """ Move plate from one internal position to another""" + orig_site = plate.parent + assert isinstance(orig_site, PlateHolder) + assert isinstance(dest_site, PlateHolder) + + if dest_site.resource is not None: + raise RuntimeError(f"Position {dest_site} already has a plate assigned!") + orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # From f4dce802827faec9564eb909e61333ce0ed00500 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Thu, 22 Jan 2026 19:00:45 +0100 Subject: [PATCH 15/35] fixed buys with move position to position successful test --- pylabrobot/storage/incubator.py | 12 ++++++++++-- pylabrobot/storage/liconic/liconic_backend.py | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 7e7c22d09b9..3359c29397b 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -270,6 +270,14 @@ async def check_second_transfer_sensor(self) -> bool: """ Check if the second transfer plate sensor is activated.""" return await self.backend.check_second_transfer_sensor() - async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): + async def move_position_to_position(self, plate_name: str, dest_site: PlateHolder, read_barcode: Optional[bool]=False) -> Plate: """ Move a plate to another internal position in the storage unit """ - return await self.backend.move_position_to_position(plate=plate, dest_site=dest_site, read_barcode=read_barcode) + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + + await self.backend.move_position_to_position(plate=plate, dest_site=dest_site, read_barcode=read_barcode) + plate.unassign() + site.assign_child_resource(plate) + + return plate diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index da55272e74e..dd277cfd704 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -230,14 +230,14 @@ async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # - await self._send_command_plc(f"WR DM 0 {orig_m}") # origin cassette # + await self._send_command_plc(f"WR DM0 {orig_m}") # origin cassette # orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) await self._send_command_plc(f"WR DM0 {orig_m}") # carousel number await self._send_command_plc(f"WR DM23 {orig_step_size}") # motor step size await self._send_command_plc(f"WR DM25 {orig_pos_num}") # number of positions in cassette - await self._send_command_plc(f"WR DM 5 {orig_n}") # origin plate position # + await self._send_command_plc(f"WR DM5 {orig_n}") # origin plate position # if read_barcode: await self.read_barcode_inline(orig_m,orig_n) From 7814fcb546e0e744b738b8ecd8a88690d5884ad8 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Thu, 22 Jan 2026 19:11:59 +0100 Subject: [PATCH 16/35] removed merge artifact in liconic backend take_in_plates --- pylabrobot/storage/liconic/liconic_backend.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index dd277cfd704..5c87d248a03 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -201,9 +201,6 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=False): """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) - await self._send_command_plc(f"WR DM0 {m}") # cassette number - await self._send_command_plc(f"WR DM23 788") - await self._send_command_plc(f"WR DM25 10") # plate position in cassette step_size, pos_num = self._carrier_to_steps_pos(site) await self._send_command_plc(f"WR DM0 {m}") # carousel number From 1efe1f944050f4adc55a58e87891d2ccbb58337f Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Fri, 23 Jan 2026 18:17:03 +0100 Subject: [PATCH 17/35] All the error handling and scan cassette function --- pylabrobot/storage/liconic/constants.py | 113 ++++++++++++ pylabrobot/storage/liconic/errors.py | 172 ++++++++++++++++++ pylabrobot/storage/liconic/liconic_backend.py | 96 ++++++++-- 3 files changed, 370 insertions(+), 11 deletions(-) create mode 100644 pylabrobot/storage/liconic/errors.py diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py index dbc9e86d4d2..691fef03cbd 100644 --- a/pylabrobot/storage/liconic/constants.py +++ b/pylabrobot/storage/liconic/constants.py @@ -60,3 +60,116 @@ class LiconicType(Enum): STX1000_DF = "STX1000_DF" # deep freezer STX1000_NC = "STX1000_NC" # no climate STX1000_DH = "STX1000_DH" # dry humid + +class ControllerError(Enum): + RELAY_ERROR = "E0" + COMMAND_ERROR = "E1" + PROGRAM_ERROR = "E2" + HARDWARE_ERROR = "E3" + WRITE_PROTECTED_ERROR = "E4" + BASE_UNIT_ERROR = "E5" + +class HandlingError(Enum): + GENERAL_HANDLING_ERROR = "00001" + GATE_OPEN_ERROR = "00007" + GATE_CLOSE_ERROR = "00008" + GENERAL_LIFT_POSITIONING_ERROR = "00009" + USER_ACCESS_ERROR = "00010" + STACKER_SLOT_ERROR = "00011" + REMOTE_ACCESS_LEVEL_ERROR = "00012" + PLATE_TRANSFER_DETECTION_ERROR = "00013" + LIFT_INITIALIZATION_ERROR = "00014" + PLATE_ON_SHOVEL_DETECTION = "00015" + NO_PLATE_ON_SHOVEL_DETECTION = "00016" + NO_RECOVERY = "00017" + + IMPORT_PLATE_STACKER_POSITIONING_ERROR = "00100" + IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR = "00101" + IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR = "00102" + IMPORT_PLATE_LIFT_TRANSFER_ERROR = "00103" + IMPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR = "00104" + IMPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR = "00105" + IMPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR = "00106" + IMPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR = "00107" + IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR = "00108" + IMPORT_PLATE_SHOVEL_STACKER_INNER_ERROR = "00109" + IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR = "00110" + IMPORT_PLATE_LIFT_INIT_ERROR = "00111" + + EXPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR = "00200" + EXPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR = "00201" + EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR = "00202" + EXPORT_PLATE_SHOVEL_STACKER_INNER_ERROR = "00203" + EXPORT_PLATE_LIFT_TRANSFER_POSITIONING_ERROR = "00204" + EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR = "00205" + EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR = "00206" + EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR = "00207" + EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR= "00208" + EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR = "00209" + EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR = "00210" + EXPORT_PLATE_LIFT_INITIALIZING_ERROR = "00211" + + PLATE_REMOVE_GENERAL_HANDLING_ERROR = "00301" + PLATE_REMOVE_GATE_OPEN_ERROR = "00307" + PLATE_REMOVE_GATE_CLOSE_ERROR = "00308" + PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR = "00309" + PLATE_REMOVE_USER_ACCESS_ERROR = "00310" + PLATE_REMOVE_STACKER_SLOT_ERROR = "00311" + PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR = "00312" + PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR = "00313" + PLATE_REMOVE_LIFT_INITIALIZATION_ERROR = "00314" + PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION = "00315" + PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION = "00316" + PLATE_REMOVE_NO_RECOVERY = "00317" + + BARCODE_READ_GENERAL_HANDLING_ERROR = "00401" + BARCODE_READ_GATE_OPEN_ERROR = "00407" + BARCODE_READ_GATE_CLOSE_ERROR = "00408" + BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR = "00409" + BARCODE_READ_USER_ACCESS_ERROR = "00410" + BARCODE_READ_STACKER_SLOT_ERROR = "00411" + BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR = "00412" + BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR = "00413" + BARCODE_READ_LIFT_INITIALIZATION_ERROR = "00414" + BARCODE_READ_PLATE_ON_SHOVEL_DETECTION = "00415" + BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION = "00416" + BARCODE_READ_NO_RECOVERY = "00417" + + PLATE_PLACE_GENERAL_HANDLING_ERROR = "00501" + PLATE_PLACE_GATE_OPEN_ERROR = "00507" + PLATE_PLACE_GATE_CLOSE_ERROR = "00508" + PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR = "00509" + PLATE_PLACE_USER_ACCESS_ERROR = "00510" + PLATE_PLACE_STACKER_SLOT_ERROR = "00511" + PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR = "00512" + PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR = "00513" + PLATE_PLACE_LIFT_INITIALIZATION_ERROR = "00514" + PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION = "00515" + PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION = "00516" + PLATE_PLACE_NO_RECOVERY = "00517" + + PLATE_SET_GENERAL_HANDLING_ERROR = "000601" + PLATE_SET_GATE_OPEN_ERROR = "00607" + PLATE_SET_GATE_CLOSE_ERROR = "00608" + PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR = "00609" + PLATE_SET_USER_ACCESS_ERROR = "00610" + PLATE_SET_STACKER_SLOT_ERROR = "00611" + PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR = "00612" + PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR = "00613" + PLATE_SET_LIFT_INITIALIZATION_ERROR = "00614" + PLATE_SET_PLATE_ON_SHOVEL_DETECTION = "00615" + PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION = "00616" + PLATE_SET_NO_RECOVERY = "00617" + + PLATE_GET_GENERAL_HANDLING_ERROR = "00701" + PLATE_GET_GATE_OPEN_ERROR = "00707" + PLATE_GET_GATE_CLOSE_ERROR = "00708" + PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR = "00709" + PLATE_GET_USER_ACCESS_ERROR = "00710" + PLATE_GET_STACKER_SLOT_ERROR = "00711" + PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR = "00712" + PLATE_GET_PLATE_TRANSFER_DETECTION_ERROR = "00713" + PLATE_GET_LIFT_INITIALIZATION_ERROR = "00714" + PLATE_GET_PLATE_ON_SHOVEL_DETECTION = "00715" + PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION = "00716" + PLATE_GET_NO_RECOVERY = "00717" diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/storage/liconic/errors.py new file mode 100644 index 00000000000..4b5d342d6fa --- /dev/null +++ b/pylabrobot/storage/liconic/errors.py @@ -0,0 +1,172 @@ +from typing import Dict + +from pylabrobot.storage.liconic.constants import ControllerError, HandlingError + +class LiconicControllerRelayError(Exception): + pass + +class LiconicControllerCommandError(Exception): + pass + +class LiconicControllerProgramError(Exception): + pass + +class LiconicControllerHardwareError(Exception): + pass + +class LiconicControllerWriteProtectedError(Exception): + pass + +class LiconicControllerBaseUnitError(Exception): + pass + +controller_error_map: Dict[ControllerError, Exception] = { + ControllerError.RELAY_ERROR: LiconicControllerRelayError( + "Controller system error. Undefined timer, counter, data memory, check if requested unit is valid" + ), + ControllerError.COMMAND_ERROR: LiconicControllerCommandError( + "Controller system error. Invalid command, check if communication is opened by CR, check command sent to controller, check for interruptions during string transmission" + ), + ControllerError.PROGRAM_ERROR: LiconicControllerProgramError( + "Controller system error. Firmware lost, reprogram controller" + ), + ControllerError.HARDWARE_ERROR: LiconicControllerHardwareError( + "Controller hardware error, turn controller ON/OFF, controller is faulty has to be replaced" + ), + ControllerError.WRITE_PROTECTED_ERROR: LiconicControllerWriteProtectedError( + "Controller system error. Unauthorized Access" + ), + ControllerError.BASE_UNIT_ERROR: LiconicControllerBaseUnitError( + "Controller system error. Unauthorized Access" + ) +} + +class LiconicHandlerPlateRemoveError(Exception): + pass + +class LiconicHandlerBarcodeReadError(Exception): + pass + +class LiconicHandlerPlatePlaceError(Exception): + pass + +class LiconicHandlerPlateSetError(Exception): + pass + +class LiconicHandlerPlateGetError(Exception): + pass + +class LiconicHandlerImportPlateError(Exception): + pass + +class LiconicHandlerExportPlateError(Exception): + pass + +class LiconicHandlerGeneralError(Exception): + pass + +handler_error_map: Dict[HandlingError, Exception] = { + HandlingError.GENERAL_HANDLING_ERROR: LiconicHandlerGeneralError("Handling action could not be performed in time"), + HandlingError.GATE_OPEN_ERROR: LiconicHandlerGeneralError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.GATE_CLOSE_ERROR: LiconicHandlerGeneralError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerGeneralError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.USER_ACCESS_ERROR: LiconicHandlerGeneralError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.STACKER_SLOT_ERROR: LiconicHandlerGeneralError("Stacker slot cannot be reached "), + HandlingError.REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerGeneralError("Undefined stacker level has been requested"), + HandlingError.PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerGeneralError("Export operation while plate is on transfer station"), + HandlingError.LIFT_INITIALIZATION_ERROR: LiconicHandlerGeneralError("Lift could not be initialized "), + HandlingError.PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.NO_RECOVERY: LiconicHandlerGeneralError("Recovery was not possible "), + + HandlingError.IMPORT_PLATE_STACKER_POSITIONING_ERROR: LiconicHandlerImportPlateError("Carrousel could not reach desired radial position during Import Plate procedure or Lift could not reach transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerImportPlateError("Handler could not reach outer turn position at transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach outer position at transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_LIFT_TRANSFER_ERROR: LiconicHandlerImportPlateError("Lift did not reach upper pick position at transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach inner position at transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerImportPlateError("Handler could not reach inner turn position at transfer level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerImportPlateError("Lift could not reach desired stacker level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerImportPlateError("Shovel could not reach front position on stacker access during Plate Import procedure."), + HandlingError.IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR: LiconicHandlerImportPlateError("Lift could not reach stacker place level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach inner position at stacker plate placement during Import Plate procedure."), + HandlingError.IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerImportPlateError("Lift could not reach zero level during Import Plate procedure."), + HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: LiconicHandlerImportPlateError("Lift could not be initialized after Import Plate procedure."), + + HandlingError.EXPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerExportPlateError("Carrousel could not reach desired radial position during Export Plate procedure or Lift could not reach desired stacker level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerExportPlateError("Shovel could not reach front position on stacker access during Plate Export procedure."), + HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: LiconicHandlerExportPlateError("Lift could not reach stacker pick level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach inner position at stacker plate pick during Export Plate procedure."), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_POSITIONING_ERROR: LiconicHandlerExportPlateError("Lift could not reach transfer level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerExportPlateError("Handler could not reach outer turn position at transfer level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach outer position at transfer level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR: LiconicHandlerExportPlateError("Lift did not reach lower place position at transfer level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach inner position at transfer level during Export Plate procedure."), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerExportPlateError("Handler could not reach inner turn position at transfer level during Export Plate procedure"), + HandlingError.EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerExportPlateError("Lift could not reach Zero position during Export Plate procedure."), + HandlingError.EXPORT_PLATE_LIFT_INITIALIZING_ERROR: LiconicHandlerExportPlateError("Lift could not be initialized after Export Plate procedure."), + + HandlingError.PLATE_REMOVE_GENERAL_HANDLING_ERROR: LiconicHandlerPlateRemoveError("Handling action could not be performed in time."), + HandlingError.PLATE_REMOVE_GATE_OPEN_ERROR: LiconicHandlerPlateRemoveError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.PLATE_REMOVE_GATE_CLOSE_ERROR: LiconicHandlerPlateRemoveError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateRemoveError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: LiconicHandlerPlateRemoveError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.PLATE_REMOVE_STACKER_SLOT_ERROR: LiconicHandlerPlateRemoveError("Stacker slot cannot be reached"), + HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateRemoveError("Undefined stacker level has been requested"), + HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateRemoveError("Export operation while plate is on transfer station"), + HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateRemoveError("Lift could not be initialized"), + HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.PLATE_REMOVE_NO_RECOVERY: LiconicHandlerPlateRemoveError("Recovery was not possible"), + + HandlingError.BARCODE_READ_GENERAL_HANDLING_ERROR: LiconicHandlerBarcodeReadError("Handling action could not be performed in time."), + HandlingError.BARCODE_READ_GATE_OPEN_ERROR: LiconicHandlerBarcodeReadError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.BARCODE_READ_GATE_CLOSE_ERROR: LiconicHandlerBarcodeReadError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerBarcodeReadError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.BARCODE_READ_USER_ACCESS_ERROR: LiconicHandlerBarcodeReadError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.BARCODE_READ_STACKER_SLOT_ERROR: LiconicHandlerBarcodeReadError("Stacker slot cannot be reached"), + HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerBarcodeReadError("Undefined stacker level has been requested"), + HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerBarcodeReadError("Export operation while plate is on transfer station"), + HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: LiconicHandlerBarcodeReadError("Lift could not be initialized"), + HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.BARCODE_READ_NO_RECOVERY: LiconicHandlerBarcodeReadError("Recovery was not possible"), + + HandlingError.PLATE_PLACE_GENERAL_HANDLING_ERROR: LiconicHandlerPlatePlaceError("Handling action could not be performed in time."), + HandlingError.PLATE_PLACE_GATE_OPEN_ERROR: LiconicHandlerPlatePlaceError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.PLATE_PLACE_GATE_CLOSE_ERROR: LiconicHandlerPlatePlaceError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlatePlaceError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: LiconicHandlerPlatePlaceError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.PLATE_PLACE_STACKER_SLOT_ERROR: LiconicHandlerPlatePlaceError("Stacker slot cannot be reached"), + HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlatePlaceError("Undefined stacker level has been requested"), + HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlatePlaceError("Export operation while plate is on transfer station"), + HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlatePlaceError("Lift could not be initialized"), + HandlingError.PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.PLATE_PLACE_NO_RECOVERY: LiconicHandlerPlatePlaceError("Recovery was not possible"), + + HandlingError.PLATE_SET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateSetError("Handling action could not be performed in time."), + HandlingError.PLATE_SET_GATE_OPEN_ERROR: LiconicHandlerPlateSetError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.PLATE_SET_GATE_CLOSE_ERROR: LiconicHandlerPlateSetError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateSetError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.PLATE_SET_USER_ACCESS_ERROR: LiconicHandlerPlateSetError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.PLATE_SET_STACKER_SLOT_ERROR: LiconicHandlerPlateSetError("Stacker slot cannot be reached"), + HandlingError.PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateSetError("Undefined stacker level has been requested"), + HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateSetError("Export operation while plate is on transfer station"), + HandlingError.PLATE_SET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateSetError("Lift could not be initialized"), + HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.PLATE_SET_NO_RECOVERY: LiconicHandlerPlateSetError("Recovery was not possible"), + + HandlingError.PLATE_GET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateGetError("Handling action could not be performed in time."), + HandlingError.PLATE_GET_GATE_OPEN_ERROR: LiconicHandlerPlateGetError("Gate could not reach upper position or Gate did not reach upper position in time"), + HandlingError.PLATE_GET_GATE_CLOSE_ERROR: LiconicHandlerPlateGetError("Gate could not reach lower position or Gate did not reach lower position in time"), + HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateGetError("Handler-Lift could not reach desired level position or does not move"), + HandlingError.PLATE_GET_USER_ACCESS_ERROR: LiconicHandlerPlateGetError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.PLATE_GET_STACKER_SLOT_ERROR: LiconicHandlerPlateGetError("Stacker slot cannot be reached"), + HandlingError.PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateGetError("Undefined stacker level has been requested"), + HandlingError.PLATE_GET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateGetError("Export operation while plate is on transfer station"), + HandlingError.PLATE_GET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateGetError("Lift could not be initialized"), + HandlingError.PLATE_GET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError("Trying to load a plate, when a plate is already on the shovel"), + HandlingError.PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.PLATE_GET_NO_RECOVERY: LiconicHandlerPlateGetError("Recovery was not possible during get plate") +} diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 5c87d248a03..bcf7d2a5446 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -13,7 +13,9 @@ from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend -from pylabrobot.storage.liconic.constants import LiconicType +from pylabrobot.storage.liconic.constants import LiconicType, ControllerError, HandlingError + +from pylabrobot.storage.liconic.errors import controller_error_map, handler_error_map logger = logging.getLogger(__name__) @@ -274,8 +276,54 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: return "No barcode" - async def scan_cassette(self,): - pass + async def scan_cassette(self, cassette:PlateCarrier): + """ Scan all barcodes in a cartridge using the internal barcode reader. Using command LON """ + if not self.barcode_installed: + raise RuntimeError("Barcode reader not installed in this incubator instance") + + await self._send_command_bcr("SSET") # enter settings mode + await self._send_command_bcr("WP121") # setting barcode scanner to multi-read mode + confirm = await self._send_command_bcr("RP12") # get read mode + if confirm != "121": + raise RuntimeError("Failed to set barcode reader to multiread mode") + + await self._send_command_bcr("WPA06000") # set barcode scanner one shot time to 60,000 ms for multiread + time_confirm = await self._send_command_bcr("RPA0") + if time_confirm != "A06000": + raise RuntimeError("Failed to set barcode reader to 60000 ms one shot time") + + await self._send_command_bcr("SEND") # exit settings mode + + assert isinstance(cassette, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + rack_idx = self._racks.index(cassette) + 1 + num_pos = len(cassette.sites.items()) # get number of positions + + await self._send_command_plc(f"WR DM0 {rack_idx}") + await self._send_command_plc("WR DM5 1") # set to first position + + await self._send_command_plc("ST 1910") # set plate shuttle to plate read level + await self._wait_ready() + + barcodes = await self._send_command_bcr("LON") # turn on barcode reader + + await self._send_command_plc(f"WR DM5 {num_pos}") + + print(f"BARCODES: {barcodes}") + + await self._send_command_bcr("SSET") # enter settings mode + await self._send_command_bcr("WP120") # setting barcode scanner to single read mode + + confirm = await self._send_command_bcr("RP12") + if confirm != "120": + raise RuntimeError("Failed to reset barcode reader to single mode") + + await self._send_command_bcr("WPA00100") # set barcode scanner one shot time to 1000 ms for single read mode + time_confirm = await self._send_command_bcr("RPA0") + if time_confirm != "A00100": + raise RuntimeError("Failed to reset barcode reader to 1000 ms one shot time") + + await self._send_command_bcr("SEND") # exit settings mode async def _send_command_plc(self, command: str) -> str: """ @@ -289,10 +337,14 @@ async def _send_command_plc(self, command: str) -> str: raise RuntimeError(f"No response from Liconic PLC for command {command!r}") resp = resp.strip() if resp.startswith("E"): - # add Liconic error handling message decoding here - raise RuntimeError(f"Error response from Liconic PLC for command {command!r}: {resp!r}") + logger.error(f"Command {command} failed with {resp}") + for member in ControllerError: + if resp == member.value: + raise controller_error_map[member] + raise RuntimeError(f"Unknown error {resp} when sending command {command}") return resp + async def _send_command_bcr(self, command: str) -> str: """ Send an ASCII command to the barcode reader over serial and return the response. @@ -325,7 +377,8 @@ async def _wait_plate_ready(self, timeout: int = 60): async def _wait_ready(self, timeout: int = 60): """ - Poll the ready-flag (RD 1915) until it is set, or timeout is reached. + Poll the ready-flag (RD 1915) until it is set. If timeout is reached + the error flag is read and if true aka "1" then the error register is read. """ start = time.time() deadline = start + timeout @@ -334,6 +387,13 @@ async def _wait_ready(self, timeout: int = 60): if resp == "1": return await asyncio.sleep(0.1) + err_flag = await self._send_command_plc("RD 1814") + if err_flag == "1": + error = await self._send_command_plc("RD DM200") + for member in HandlingError: + if error == member.value: + raise handler_error_map[member] + raise RuntimeError(f" Liconic Handler in unknown error state with memory showing {error}") raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") async def set_temperature(self, temperature: float): @@ -551,16 +611,30 @@ async def check_second_transfer_sensor(self) -> bool: else: raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") - async def scan_barcode(self, cassette: int, position: int, pitch: int, plate_count: int) -> str: + async def scan_barcode(self, site: PlateHolder) -> str: """ Scan a barcode using the internal barcode reader. Using command LON """ if not self.barcode_installed: raise RuntimeError("Barcode reader not installed in this incubator instance") - await self._send_command_plc(f"WR DM0 {cassette}") # carousel number - await self._send_command_plc(f"WR DM23 {pitch}") # pitch of plate in mm - await self._send_command_plc(f"WR DM25 {plate_count}") # plate - await self._send_command_plc(f"WR DM5 {position}") # plate position in carousel + m, n = self._site_to_m_n(site) + step_size, pos_num = self._carrier_to_steps_pos(site) + + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # pitch of plate in mm + await self._send_command_plc(f"WR DM25 {pos_num}") # plate + await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel await self._send_command_plc("ST 1910") # move shovel to barcode reading position barcode = await self._send_command_bcr("LON") print(f"Scanned barcode: {barcode}") + return barcode + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self.io_plc.port, + } + + @classmethod + def deserialize(cls, data: dict): + return cls(port=data["port"]) From 57fe9c7d8f90e2d84c5b046ee65e5ae0c9706043 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Fri, 23 Jan 2026 18:24:05 +0100 Subject: [PATCH 18/35] bug fixes --- pylabrobot/storage/incubator.py | 4 ++++ pylabrobot/storage/liconic/liconic_backend.py | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 3359c29397b..50da2c30225 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -281,3 +281,7 @@ async def move_position_to_position(self, plate_name: str, dest_site: PlateHolde site.assign_child_resource(plate) return plate + + async def scan_cassette(self, cassette: PlateCarrier): + """ Scan all positions in a single cassette aka carrier""" + return await self.backend.scan_cassette(cassette) \ No newline at end of file diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index bcf7d2a5446..b760e807bbd 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -352,7 +352,6 @@ async def _send_command_bcr(self, command: str) -> str: cmd = command.strip() + "\r" logger.debug(f"Sending command to Barcode Reader: {cmd!r}") resp = await self.io_bcr.send_command(cmd) - #resp = (await self.io_bcr.read(128)).decode(self.serial_message_encoding) if not resp: raise RuntimeError(f"No response from Barcode Reader for command {command!r}") resp = resp.strip() From 42462df70b772c8d0eb97beeab6ea104763e817d Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Mon, 26 Jan 2026 16:27:46 +0100 Subject: [PATCH 19/35] continuous streaming on BCR --- .../keyence/barcode_scanner_backend.py | 45 +++++++++++++++++++ pylabrobot/storage/liconic/liconic_backend.py | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index eb0869e2eb4..cb0624fbb9d 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -7,6 +7,7 @@ import serial import time +from typing import Optional from pylabrobot.io.serial import Serial class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): @@ -61,6 +62,50 @@ async def send_command(self, command: str) -> str: response = await self.io.readline() return response.decode(self.serial_messaging_encoding).strip() + async def _send_command_and_stream( + self, + command: str, + timeout: float = 5.0, + stop_condition: Optional[callable] = None +) -> list[str]: + """Send a command and receive a stream of responses until timeout or stop condition. + + Args: + command: The command to send to the barcode scanner + timeout: Maximum time in seconds to wait for responses + stop_condition: Optional callable that returns True when to stop reading. + Takes a response string and returns bool. + + Returns: + A list of response strings received from the scanner + """ + await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) + + responses = [] + deadline = time.time() + timeout + + while time.time() < deadline: + try: + # Set a short timeout for individual reads to allow checking deadline + response = await asyncio.wait_for( + self.io.readline(), + timeout=0.1 + ) + decoded = response.decode(self.serial_messaging_encoding).strip() + + if decoded: # Only add non-empty responses + responses.append(decoded) + + # Check stop condition if provided + if stop_condition and stop_condition(decoded): + break + + except asyncio.TimeoutError: + # No data available, continue waiting until deadline + continue + + return responses + async def stop(self): await self.io.stop() diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index b760e807bbd..dafa4e1f42e 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -305,7 +305,7 @@ async def scan_cassette(self, cassette:PlateCarrier): await self._send_command_plc("ST 1910") # set plate shuttle to plate read level await self._wait_ready() - barcodes = await self._send_command_bcr("LON") # turn on barcode reader + barcodes = await self.io_bcr._send_command_and_stream("LON", 30.0) # turn on barcode reader and stream response for 30s await self._send_command_plc(f"WR DM5 {num_pos}") From ac623474c30532e672e23072a67b7d6df5354b98 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Mon, 26 Jan 2026 16:56:58 +0100 Subject: [PATCH 20/35] generator function --- .../keyence/barcode_scanner_backend.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index cb0624fbb9d..23ce5d1a73a 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -62,13 +62,13 @@ async def send_command(self, command: str) -> str: response = await self.io.readline() return response.decode(self.serial_messaging_encoding).strip() - async def _send_command_and_stream( + async def send_command_and_stream( self, command: str, timeout: float = 5.0, stop_condition: Optional[callable] = None -) -> list[str]: - """Send a command and receive a stream of responses until timeout or stop condition. +): + """Send a command and yield responses as an async generator. Args: command: The command to send to the barcode scanner @@ -76,36 +76,31 @@ async def _send_command_and_stream( stop_condition: Optional callable that returns True when to stop reading. Takes a response string and returns bool. - Returns: - A list of response strings received from the scanner + Yields: + Response strings from the scanner as they arrive """ await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) - responses = [] deadline = time.time() + timeout while time.time() < deadline: try: - # Set a short timeout for individual reads to allow checking deadline response = await asyncio.wait_for( self.io.readline(), timeout=0.1 ) decoded = response.decode(self.serial_messaging_encoding).strip() - if decoded: # Only add non-empty responses - responses.append(decoded) + if decoded: # Only yield non-empty responses + yield decoded # Check stop condition if provided if stop_condition and stop_condition(decoded): break except asyncio.TimeoutError: - # No data available, continue waiting until deadline continue - return responses - async def stop(self): await self.io.stop() From 6058913fa34a9260fd1b916aad0a6a1bec5866c1 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Mon, 26 Jan 2026 17:23:24 +0100 Subject: [PATCH 21/35] still not working --- .../keyence/barcode_scanner_backend.py | 42 ++++++++----------- pylabrobot/storage/liconic/liconic_backend.py | 6 +-- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index 23ce5d1a73a..63f8cdeed5f 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -65,40 +65,32 @@ async def send_command(self, command: str) -> str: async def send_command_and_stream( self, command: str, + on_response: callable, timeout: float = 5.0, stop_condition: Optional[callable] = None ): - """Send a command and yield responses as an async generator. - - Args: - command: The command to send to the barcode scanner - timeout: Maximum time in seconds to wait for responses - stop_condition: Optional callable that returns True when to stop reading. - Takes a response string and returns bool. - - Yields: - Response strings from the scanner as they arrive - """ + """Send a command and call on_response for each barcode response.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) - deadline = time.time() + timeout while time.time() < deadline: try: - response = await asyncio.wait_for( - self.io.readline(), - timeout=0.1 - ) - decoded = response.decode(self.serial_messaging_encoding).strip() - - if decoded: # Only yield non-empty responses - yield decoded - - # Check stop condition if provided - if stop_condition and stop_condition(decoded): - break - + response = await asyncio.wait_for(self.io.readline(), timeout=1.0) + if response: + decoded = response.decode(self.serial_messaging_encoding).strip() + print(f"Received from barcode scanner: {decoded}") + if decoded: + try: + await on_response(decoded) # Call the callback + except Exception as e: + print(f"Error in callback: {e}") + if stop_condition and stop_condition(decoded): + break except asyncio.TimeoutError: + print("Barcode scanner timeout, continuing...") + continue + except Exception as e: + print(f"Error reading from barcode scanner: {e}") continue async def stop(self): diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index dafa4e1f42e..77e384bbe74 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -275,6 +275,8 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: logger.info(" Barcode reading requested but instance not configured with barcode reader.") return "No barcode" + async def handle_barcode(self, code: str): + print(f"Got barcode: {code}") async def scan_cassette(self, cassette:PlateCarrier): """ Scan all barcodes in a cartridge using the internal barcode reader. Using command LON """ @@ -305,12 +307,10 @@ async def scan_cassette(self, cassette:PlateCarrier): await self._send_command_plc("ST 1910") # set plate shuttle to plate read level await self._wait_ready() - barcodes = await self.io_bcr._send_command_and_stream("LON", 30.0) # turn on barcode reader and stream response for 30s + asyncio.create_task(self.io_bcr.send_command_and_stream("LON", on_response=self.handle_barcode, timeout=30.0)) # turn on barcode reader and stream response for 30s await self._send_command_plc(f"WR DM5 {num_pos}") - print(f"BARCODES: {barcodes}") - await self._send_command_bcr("SSET") # enter settings mode await self._send_command_bcr("WP120") # setting barcode scanner to single read mode From a74051d9fd0b93561300b76bfd9a3e2eacdc9446 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Tue, 27 Jan 2026 16:01:21 +0100 Subject: [PATCH 22/35] How to guide rough --- .../storage/liconic.ipynb | 31 ++++++++++++ pylabrobot/storage/incubator.py | 4 -- pylabrobot/storage/liconic/liconic_backend.py | 50 ------------------- 3 files changed, 31 insertions(+), 54 deletions(-) create mode 100644 docs/user_guide/01_material-handling/storage/liconic.ipynb diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb new file mode 100644 index 00000000000..e4a1ee84e4b --- /dev/null +++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb @@ -0,0 +1,31 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b63b4656", + "metadata": {}, + "source": [ + "Liconic STX Series\n", + "\n", + "The Liconic STX line of automated incubators come in a variety of sizes including STX 1000, STX 500, STX 280, STX 220, STX 110, STX 44. Which corresponds to the number of plates each size can store using the standard 22 plate capacity cassettes/cartridges (plate height 17mm, 505mm total height.) There are other cassette size for plates height ranging from 5 to 104mm in height (higher plates = less number of plates storage capacity.)\n", + "\n", + "The Liconic STX line comes in a variety of climate control options including Ultra High Temp. (HTT), Incubator (IC), Dry Storage (DC2), Humid Cooler (HC), Humid Wide Range (HR), Dry Wide Range (DR2), Humidity Controlled (AR), Deep Freezer (DF) and Ultra Deep Freezer (UDF). Each have different ranges of temperatures and humidity control ability.\n", + "\n", + "Other accessories that can be included with the STX include N2 gassing, CO2 gassing, a Turn Station (rotation of plates 90 degrees on the transfer station), internal barcode scanners, a swap station (two transfer plate positions that can be rotated 180 degrees) and internal shaking. \n", + "\n", + "This tutorial shows how to\n", + " - Connect the Liconic incubator\n", + " - Configure racks\n", + " - Move plates in and out\n", + " - Set and monitor temperature and humidity values" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 50da2c30225..3359c29397b 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -281,7 +281,3 @@ async def move_position_to_position(self, plate_name: str, dest_site: PlateHolde site.assign_child_resource(plate) return plate - - async def scan_cassette(self, cassette: PlateCarrier): - """ Scan all positions in a single cassette aka carrier""" - return await self.backend.scan_cassette(cassette) \ No newline at end of file diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index b760e807bbd..456761f732e 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -275,56 +275,6 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: logger.info(" Barcode reading requested but instance not configured with barcode reader.") return "No barcode" - - async def scan_cassette(self, cassette:PlateCarrier): - """ Scan all barcodes in a cartridge using the internal barcode reader. Using command LON """ - if not self.barcode_installed: - raise RuntimeError("Barcode reader not installed in this incubator instance") - - await self._send_command_bcr("SSET") # enter settings mode - await self._send_command_bcr("WP121") # setting barcode scanner to multi-read mode - confirm = await self._send_command_bcr("RP12") # get read mode - if confirm != "121": - raise RuntimeError("Failed to set barcode reader to multiread mode") - - await self._send_command_bcr("WPA06000") # set barcode scanner one shot time to 60,000 ms for multiread - time_confirm = await self._send_command_bcr("RPA0") - if time_confirm != "A06000": - raise RuntimeError("Failed to set barcode reader to 60000 ms one shot time") - - await self._send_command_bcr("SEND") # exit settings mode - - assert isinstance(cassette, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(cassette) + 1 - num_pos = len(cassette.sites.items()) # get number of positions - - await self._send_command_plc(f"WR DM0 {rack_idx}") - await self._send_command_plc("WR DM5 1") # set to first position - - await self._send_command_plc("ST 1910") # set plate shuttle to plate read level - await self._wait_ready() - - barcodes = await self._send_command_bcr("LON") # turn on barcode reader - - await self._send_command_plc(f"WR DM5 {num_pos}") - - print(f"BARCODES: {barcodes}") - - await self._send_command_bcr("SSET") # enter settings mode - await self._send_command_bcr("WP120") # setting barcode scanner to single read mode - - confirm = await self._send_command_bcr("RP12") - if confirm != "120": - raise RuntimeError("Failed to reset barcode reader to single mode") - - await self._send_command_bcr("WPA00100") # set barcode scanner one shot time to 1000 ms for single read mode - time_confirm = await self._send_command_bcr("RPA0") - if time_confirm != "A00100": - raise RuntimeError("Failed to reset barcode reader to 1000 ms one shot time") - - await self._send_command_bcr("SEND") # exit settings mode - async def _send_command_plc(self, command: str) -> str: """ Send an ASCII command to the Liconic PLC over serial and return the response. From 74683f5af959564593c389b217a8e5ae6552afd8 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Tue, 27 Jan 2026 16:02:46 +0100 Subject: [PATCH 23/35] cleanup of imports --- pylabrobot/storage/liconic/liconic_backend.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 456761f732e..d6aea1cc3f2 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -2,9 +2,8 @@ import logging import time import warnings -from typing import List, Tuple, Optional, Union, cast -import re from typing import List, Tuple, Optional, Union +import re import serial From a4bb914bdfe97a58f0df34c9e45c3a96bc294567 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Tue, 27 Jan 2026 16:49:36 +0100 Subject: [PATCH 24/35] More edits and update to doc --- .../storage/liconic.ipynb | 222 +++++++++++++++++- pylabrobot/storage/incubator.py | 4 - pylabrobot/storage/liconic/liconic_backend.py | 80 ++----- 3 files changed, 242 insertions(+), 64 deletions(-) diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb index e4a1ee84e4b..a21d84d8882 100644 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb @@ -5,13 +5,13 @@ "id": "b63b4656", "metadata": {}, "source": [ - "Liconic STX Series\n", + "

Liconic STX Series

\n", "\n", "The Liconic STX line of automated incubators come in a variety of sizes including STX 1000, STX 500, STX 280, STX 220, STX 110, STX 44. Which corresponds to the number of plates each size can store using the standard 22 plate capacity cassettes/cartridges (plate height 17mm, 505mm total height.) There are other cassette size for plates height ranging from 5 to 104mm in height (higher plates = less number of plates storage capacity.)\n", "\n", "The Liconic STX line comes in a variety of climate control options including Ultra High Temp. (HTT), Incubator (IC), Dry Storage (DC2), Humid Cooler (HC), Humid Wide Range (HR), Dry Wide Range (DR2), Humidity Controlled (AR), Deep Freezer (DF) and Ultra Deep Freezer (UDF). Each have different ranges of temperatures and humidity control ability.\n", "\n", - "Other accessories that can be included with the STX include N2 gassing, CO2 gassing, a Turn Station (rotation of plates 90 degrees on the transfer station), internal barcode scanners, a swap station (two transfer plate positions that can be rotated 180 degrees) and internal shaking. \n", + "Other accessories that can be included with the STX and can be utilized with this driver include N2 gassing, CO2 gassing, a Turn Station (rotation of plates 90 degrees on the transfer station), internal barcode scanners, a swap station (two transfer plate positions that can be rotated 180 degrees) and internal shaking. \n", "\n", "This tutorial shows how to\n", " - Connect the Liconic incubator\n", @@ -19,6 +19,224 @@ " - Move plates in and out\n", " - Set and monitor temperature and humidity values" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fcd75e15", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "from pylabrobot.resources.coordinate import Coordinate\n", + "from pylabrobot.storage import LiconicBackend\n", + "from pylabrobot.storage.incubator import Incubator\n", + "from pylabrobot.storage.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\n", + "\n", + "backend = LiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_installed=True, barcode_port=\"COM4\")\n", + "\n", + "rack = [\n", + " liconic_rack_44mm_10(\"cassette_0\"),\n", + " liconic_rack_44mm_10(\"cassette_1\"),\n", + " liconic_rack_44mm_10(\"cassette_2\"),\n", + " liconic_rack_44mm_10(\"cassette_3\"),\n", + " liconic_rack_17mm_22(\"cassette_4\"),\n", + " liconic_rack_17mm_22(\"cassette_5\"),\n", + " liconic_rack_17mm_22(\"cassette_6\"),\n", + " liconic_rack_17mm_22(\"cassette_7\"),\n", + " liconic_rack_17mm_22(\"cassette_8\"),\n", + " liconic_rack_17mm_22(\"cassette_9\")\n", + " ]\n", + "\n", + "incubator = Incubator(\n", + " backend=backend,\n", + " name=\"My Incubator\",\n", + " size_x=100,\n", + " size_y=100,\n", + " size_z=100,\n", + " racks=rack,\n", + " loading_tray_location=Coordinate(x=0, y=0, z=0),\n", + ")\n", + "\n", + "await incubator.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "19b3a6cc", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "source": [ + "Setup\n", + "\n", + "To setup the incubator and start sending commands first the backed needs to be declared. For the Liconic the LiconcBackend class is used with the COM port used for connection (in this case COM3) and the model needs to specified (in this case the STX 220 Humid Cooler, STX220_HC). If an internal barcode is installed the barcode_installed parameter is set to True and its COM port is also specified. These two parameters are optional so can be omitted for Liconics without an internal barcode scanner. \n", + "\n", + "Given a STX 220 (220 plate position / 22 plates per rack = 10 racks) can hold 10 racks the list of racks is built and includes mixing and matching different plate height racks. The differences in racks are handled prior to plate retrieval and storage. \n", + "\n", + "Once the these are built the base Incubator class is created and the connection to the incubator is initialized using:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d7a4f49", + "metadata": {}, + "outputs": [], + "source": [ + "await incubator.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "52f79811", + "metadata": {}, + "source": [ + "To store a plate first a plate resource is initialized and then assigned to the loading tray. The method take_in_plate is then called on the incubator object.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d26e039d", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n", + "\n", + "new_plate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"TEST\")\n", + "incubator.loading_tray.assign_child_resource(new_plate)\n", + "await incubator.take_in_plate(\"smallest\") # choose the smallest free site\n", + "\n", + "# other options:\n", + "# await incubator.take_in_plate(\"random\") # random free site\n", + "# await incubator.take_in_plate(rack[3]) # store at rack position 3" + ] + }, + { + "cell_type": "markdown", + "id": "85dcddb7", + "metadata": {}, + "source": [ + "To retrieve a plate the plate name can used" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00308838", + "metadata": {}, + "outputs": [], + "source": [ + "await incubator.fetch_plate_to_loading_tray(plate=\"TEST\")\n", + "retrieved = incubator.loading_tray.resource" + ] + }, + { + "cell_type": "markdown", + "id": "0b2433b2", + "metadata": {}, + "source": [ + "Or the plate holder position (in this case rack 9 position 1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38587c18", + "metadata": {}, + "outputs": [], + "source": [ + "await incubator.fetch_plate_to_loading_tray(plate=None,site=rack[9][1])\n", + "retrieved = incubator.loading_tray.resource\n" + ] + }, + { + "cell_type": "markdown", + "id": "0045e703", + "metadata": {}, + "source": [ + "You can also return the barcode as a string from this call (if barcode is installed per the backend insatiation)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c8730560", + "metadata": {}, + "outputs": [], + "source": [ + "barcode = await incubator.fetch_plate_to_loading_tray(plate=\"TEST\",read_barcode=True)\n", + "print(barcode)" + ] + }, + { + "cell_type": "markdown", + "id": "14efdf69", + "metadata": {}, + "source": [ + "The humdity, temperature, N2 gas and CO2 gas levels can all be controlled and queried. For temperature for example:\n", + "\n", + "- To get the current temperature" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73e38f2a", + "metadata": {}, + "outputs": [], + "source": [ + "temperature = await incubator.get_temperature() # returns temperature as float in Celsius to the 10th place\n", + "print(str(temperature))" + ] + }, + { + "cell_type": "markdown", + "id": "c7383277", + "metadata": {}, + "source": [ + "- To set the temperature of the Liconic" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c51c385", + "metadata": {}, + "outputs": [], + "source": [ + "await incubator.set_temperature(8.0) # set the temperature to 8 degrees Celsius" + ] + }, + { + "cell_type": "markdown", + "id": "4f07f349", + "metadata": {}, + "source": [ + "- You can also retrieve the set value (the value sent for set_temperature) using:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "288dea91", + "metadata": {}, + "outputs": [], + "source": [ + "set_temperature = await incubator.get_set_temperature() # will return a float for the set temperature in degrees Celsius" + ] + }, + { + "cell_type": "markdown", + "id": "3a1d9ef3", + "metadata": {}, + "source": [ + "This pattern is the same for CO2, N2 and Humidity control of the Liconic. " + ] } ], "metadata": { diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 50da2c30225..3359c29397b 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -281,7 +281,3 @@ async def move_position_to_position(self, plate_name: str, dest_site: PlateHolde site.assign_child_resource(plate) return plate - - async def scan_cassette(self, cassette: PlateCarrier): - """ Scan all positions in a single cassette aka carrier""" - return await self.backend.scan_cassette(cassette) \ No newline at end of file diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 77e384bbe74..e2787aa62fe 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -2,9 +2,8 @@ import logging import time import warnings -from typing import List, Tuple, Optional, Union, cast -import re from typing import List, Tuple, Optional, Union +import re import serial @@ -180,10 +179,19 @@ async def close_door(self): await self._send_command_plc("ST 1902") await self._wait_ready() - async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read_barcode: Optional[bool]=False): + async def fetch_plate_to_loading_tray(self, plate: Optional[Plate]=None, site: Optional[PlateHolder]=None, read_barcode: Optional[bool]=False) -> str: """ Fetch a plate from the incubator to the loading tray.""" - site = plate.parent - assert isinstance(site, PlateHolder), "Plate not in storage" + if plate and not site: + site = plate.parent + assert isinstance(site, PlateHolder), "Plate not in storage" + elif site and not plate: + site = site + assert isinstance(site, PlateHolder), "Plate holder not found" + elif site and plate: + if plate.parent != site: + raise RuntimeError(f"The requested plate {plate} is not the plate in {site}") + else: + raise RuntimeError("Please provide a plate or plate holder location") m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -194,13 +202,16 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder, read await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel if read_barcode: - await self.read_barcode_inline(m,n) + barcode = await self.read_barcode_inline(m,n) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=False): + if read_barcode: + return barcode + + async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=False) -> str: """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -213,10 +224,13 @@ async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=Fa await self._wait_ready() if read_barcode: - await self.read_barcode_inline(m,n) + barcode = await self.read_barcode_inline(m,n) await self._send_command_plc("ST 1903") # terminate access + if read_barcode: + return barcode + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): """ Move plate from one internal position to another""" orig_site = plate.parent @@ -275,56 +289,6 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: logger.info(" Barcode reading requested but instance not configured with barcode reader.") return "No barcode" - async def handle_barcode(self, code: str): - print(f"Got barcode: {code}") - - async def scan_cassette(self, cassette:PlateCarrier): - """ Scan all barcodes in a cartridge using the internal barcode reader. Using command LON """ - if not self.barcode_installed: - raise RuntimeError("Barcode reader not installed in this incubator instance") - - await self._send_command_bcr("SSET") # enter settings mode - await self._send_command_bcr("WP121") # setting barcode scanner to multi-read mode - confirm = await self._send_command_bcr("RP12") # get read mode - if confirm != "121": - raise RuntimeError("Failed to set barcode reader to multiread mode") - - await self._send_command_bcr("WPA06000") # set barcode scanner one shot time to 60,000 ms for multiread - time_confirm = await self._send_command_bcr("RPA0") - if time_confirm != "A06000": - raise RuntimeError("Failed to set barcode reader to 60000 ms one shot time") - - await self._send_command_bcr("SEND") # exit settings mode - - assert isinstance(cassette, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(cassette) + 1 - num_pos = len(cassette.sites.items()) # get number of positions - - await self._send_command_plc(f"WR DM0 {rack_idx}") - await self._send_command_plc("WR DM5 1") # set to first position - - await self._send_command_plc("ST 1910") # set plate shuttle to plate read level - await self._wait_ready() - - asyncio.create_task(self.io_bcr.send_command_and_stream("LON", on_response=self.handle_barcode, timeout=30.0)) # turn on barcode reader and stream response for 30s - - await self._send_command_plc(f"WR DM5 {num_pos}") - - await self._send_command_bcr("SSET") # enter settings mode - await self._send_command_bcr("WP120") # setting barcode scanner to single read mode - - confirm = await self._send_command_bcr("RP12") - if confirm != "120": - raise RuntimeError("Failed to reset barcode reader to single mode") - - await self._send_command_bcr("WPA00100") # set barcode scanner one shot time to 1000 ms for single read mode - time_confirm = await self._send_command_bcr("RPA0") - if time_confirm != "A00100": - raise RuntimeError("Failed to reset barcode reader to 1000 ms one shot time") - - await self._send_command_bcr("SEND") # exit settings mode - async def _send_command_plc(self, command: str) -> str: """ Send an ASCII command to the Liconic PLC over serial and return the response. From 8e085daf3b84974729757dedca9476b5e258c6e2 Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Tue, 27 Jan 2026 17:56:50 +0100 Subject: [PATCH 25/35] bug fixes --- .../storage/liconic.ipynb | 27 +++-------------- .../keyence/barcode_scanner_backend.py | 8 ++++- pylabrobot/storage/incubator.py | 4 +-- pylabrobot/storage/liconic/liconic_backend.py | 29 +++++-------------- 4 files changed, 20 insertions(+), 48 deletions(-) diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb index a21d84d8882..bc5894b21a9 100644 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb @@ -39,7 +39,7 @@ " liconic_rack_44mm_10(\"cassette_0\"),\n", " liconic_rack_44mm_10(\"cassette_1\"),\n", " liconic_rack_44mm_10(\"cassette_2\"),\n", - " liconic_rack_44mm_10(\"cassette_3\"),\n", + " liconic_rack_17mm_22(\"cassette_3\"),\n", " liconic_rack_17mm_22(\"cassette_4\"),\n", " liconic_rack_17mm_22(\"cassette_5\"),\n", " liconic_rack_17mm_22(\"cassette_6\"),\n", @@ -135,31 +135,12 @@ "retrieved = incubator.loading_tray.resource" ] }, - { - "cell_type": "markdown", - "id": "0b2433b2", - "metadata": {}, - "source": [ - "Or the plate holder position (in this case rack 9 position 1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38587c18", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.fetch_plate_to_loading_tray(plate=None,site=rack[9][1])\n", - "retrieved = incubator.loading_tray.resource\n" - ] - }, { "cell_type": "markdown", "id": "0045e703", "metadata": {}, "source": [ - "You can also return the barcode as a string from this call (if barcode is installed per the backend insatiation)" + "You can also print a barcode from this call (if barcode is installed per the backend insatiation). Returning of the barcode as a return object still needs to be implemented. " ] }, { @@ -169,8 +150,8 @@ "metadata": {}, "outputs": [], "source": [ - "barcode = await incubator.fetch_plate_to_loading_tray(plate=\"TEST\",read_barcode=True)\n", - "print(barcode)" + "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\",read_barcode=True)\n", + "# will print the barcode to the terminal" ] }, { diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index 63f8cdeed5f..b9d71e6ca88 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -59,7 +59,13 @@ async def send_command(self, command: str) -> str: Keyence uses carriage return \r as the line ending by default.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) - response = await self.io.readline() + deadline = time.time() + 5.0 + while time.time() < deadline: + response = await self.io.readline() + if response: + break + await asyncio.sleep(self.poll_interval) + return response.decode(self.serial_messaging_encoding).strip() async def send_command_and_stream( diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 3359c29397b..77f779e6717 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -73,13 +73,13 @@ def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: return site raise ResourceNotFoundError(f"Plate {plate_name} not found in incubator '{self.name}'") - async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + async def fetch_plate_to_loading_tray(self, plate_name: str, read_barcode: Optional[bool]=False) -> Plate: """Fetch a plate from the incubator and put it on the loading tray.""" site = self.get_site_by_plate_name(plate_name) plate = site.resource assert plate is not None - await self.backend.fetch_plate_to_loading_tray(plate) + await self.backend.fetch_plate_to_loading_tray(plate, read_barcode) plate.unassign() self.loading_tray.assign_child_resource(plate) return plate diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index e2787aa62fe..5071c48f498 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -179,19 +179,10 @@ async def close_door(self): await self._send_command_plc("ST 1902") await self._wait_ready() - async def fetch_plate_to_loading_tray(self, plate: Optional[Plate]=None, site: Optional[PlateHolder]=None, read_barcode: Optional[bool]=False) -> str: + async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[bool]=False) -> str: """ Fetch a plate from the incubator to the loading tray.""" - if plate and not site: - site = plate.parent - assert isinstance(site, PlateHolder), "Plate not in storage" - elif site and not plate: - site = site - assert isinstance(site, PlateHolder), "Plate holder not found" - elif site and plate: - if plate.parent != site: - raise RuntimeError(f"The requested plate {plate} is not the plate in {site}") - else: - raise RuntimeError("Please provide a plate or plate holder location") + site = plate.parent + assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -203,15 +194,13 @@ async def fetch_plate_to_loading_tray(self, plate: Optional[Plate]=None, site: O if read_barcode: barcode = await self.read_barcode_inline(m,n) + print(barcode) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - if read_barcode: - return barcode - - async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=False) -> str: + async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False) -> str: """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -225,12 +214,10 @@ async def take_in_plate(self, site: PlateHolder, read_barcode: Optional[bool]=Fa if read_barcode: barcode = await self.read_barcode_inline(m,n) + print(barcode) await self._send_command_plc("ST 1903") # terminate access - if read_barcode: - return barcode - async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): """ Move plate from one internal position to another""" orig_site = plate.parent @@ -313,9 +300,7 @@ async def _send_command_bcr(self, command: str) -> str: """ Send an ASCII command to the barcode reader over serial and return the response. """ - cmd = command.strip() + "\r" - logger.debug(f"Sending command to Barcode Reader: {cmd!r}") - resp = await self.io_bcr.send_command(cmd) + resp = await self.io_bcr.send_command(command) if not resp: raise RuntimeError(f"No response from Barcode Reader for command {command!r}") resp = resp.strip() From 0c9b52b8e4d854f46362e387cc97680e07b8f3e0 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 28 Jan 2026 10:24:44 +0100 Subject: [PATCH 26/35] fix for barcode scan --- .../barcode_scanners/keyence/barcode_scanner_backend.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index b9d71e6ca88..f5610ff8af0 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -59,13 +59,7 @@ async def send_command(self, command: str) -> str: Keyence uses carriage return \r as the line ending by default.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) - deadline = time.time() + 5.0 - while time.time() < deadline: - response = await self.io.readline() - if response: - break - await asyncio.sleep(self.poll_interval) - + response = await self.io.read() return response.decode(self.serial_messaging_encoding).strip() async def send_command_and_stream( From 56898b4f200dce75db515f3541813151fda834ee Mon Sep 17 00:00:00 2001 From: sam-adaptyv Date: Wed, 28 Jan 2026 10:44:07 +0100 Subject: [PATCH 27/35] barcode fix --- .../storage/liconic.ipynb | 26 +++++++++++++++++++ pylabrobot/storage/incubator.py | 4 +-- pylabrobot/storage/liconic/liconic_backend.py | 3 ++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb index bc5894b21a9..bc7d91d3652 100644 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb @@ -116,6 +116,32 @@ "# await incubator.take_in_plate(rack[3]) # store at rack position 3" ] }, + { + "cell_type": "markdown", + "id": "d7cf08c2", + "metadata": {}, + "source": [ + "Barcode can returned " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b9cf2ed", + "metadata": {}, + "outputs": [], + "source": [ + "await incubator.move_position_to_position(plate_name=\"TEST\",dest_site=position,read_barcode=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dc9057e", + "metadata": {}, + "outputs": [], + "source": [] + }, { "cell_type": "markdown", "id": "85dcddb7", diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 77f779e6717..231785fe152 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -112,7 +112,7 @@ def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: def find_random_site(self, plate: Plate) -> PlateHolder: return random.choice(self._find_available_sites_sorted(plate)) - async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]], read_barcode: Optional[bool] = False): """Take a plate from the loading tray and put it in the incubator.""" plate = cast(Plate, self.loading_tray.resource) @@ -128,7 +128,7 @@ async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smalle raise ValueError(f"Site {site.name} is not available for plate {plate.name}") else: raise ValueError(f"Invalid site: {site}") - await self.backend.take_in_plate(plate, site) + await self.backend.take_in_plate(plate, site, read_barcode) plate.unassign() site.assign_child_resource(plate) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 5071c48f498..40ac845f0f0 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -240,7 +240,8 @@ async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, await self._send_command_plc(f"WR DM5 {orig_n}") # origin plate position # if read_barcode: - await self.read_barcode_inline(orig_m,orig_n) + barcode = await self.read_barcode_inline(orig_m,orig_n) + print(barcode) await self._send_command_plc("ST 1908") # pick plate from origin position From 2c311030af2b9abd88a1748fc257710f49497fac Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 28 Jan 2026 11:10:51 +0100 Subject: [PATCH 28/35] Final fixes --- pylabrobot/storage/backend.py | 7 ++++- pylabrobot/storage/incubator.py | 31 +++++++++++++++---- pylabrobot/storage/liconic/liconic_backend.py | 17 +++++++--- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py index c3d5bd9e44c..a1194b74dec 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/storage/backend.py @@ -126,4 +126,9 @@ async def check_second_transfer_sensor(self) -> bool: @abstractmethod async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): """Scan barcode at given position with specified pitch and timeout.""" - pass \ No newline at end of file + pass + + @abstractmethod + async def move_position_to_position(self, plate_name: str, dest_site: PlateHolder): + """ Move plate by name to another position in the storage unit""" + pass diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index 231785fe152..e35b23dff41 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -79,7 +79,14 @@ async def fetch_plate_to_loading_tray(self, plate_name: str, read_barcode: Optio site = self.get_site_by_plate_name(plate_name) plate = site.resource assert plate is not None - await self.backend.fetch_plate_to_loading_tray(plate, read_barcode) + + if read_barcode: + barcode = await self.backend.fetch_plate_to_loading_tray(plate, read_barcode) + print(barcode) + # undecided with what we want to do with barcode string (no Plate variable for it) + else: + await self.backend.fetch_plate_to_loading_tray(plate) + plate.unassign() self.loading_tray.assign_child_resource(plate) return plate @@ -128,7 +135,14 @@ async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smalle raise ValueError(f"Site {site.name} is not available for plate {plate.name}") else: raise ValueError(f"Invalid site: {site}") - await self.backend.take_in_plate(plate, site, read_barcode) + + if read_barcode: + barcode = await self.backend.take_in_plate(plate, site, read_barcode) + print(barcode) + # undecided with what we want to do with barcode string (no Plate variable for it) + else: + await self.backend.take_in_plate(plate, site) + plate.unassign() site.assign_child_resource(plate) @@ -151,9 +165,8 @@ async def start_shaking(self, frequency: float = 1.0): async def stop_shaking(self): await self.backend.stop_shaking() - # REDO - async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): - await self.backend.scan_barcode(cassette=m, position=n, pitch=pitch, plate_count=plt_count) + async def scan_barcode(self, site: PlateHolder): + await self.backend.scan_barcode(self, site) def summary(self) -> str: def create_pretty_table(header, *columns) -> str: @@ -276,7 +289,13 @@ async def move_position_to_position(self, plate_name: str, dest_site: PlateHolde plate = site.resource assert plate is not None - await self.backend.move_position_to_position(plate=plate, dest_site=dest_site, read_barcode=read_barcode) + if read_barcode: + barcode = await self.backend.move_position_to_position(plate, dest_site, read_barcode) + print(barcode) + # undecided with what we want to do with barcode string (no Plate variable for it) + else: + await self.backend.move_position_to_position(plate,dest_site) + plate.unassign() site.assign_child_resource(plate) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 40ac845f0f0..7bda2e4b8b0 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -179,7 +179,7 @@ async def close_door(self): await self._send_command_plc("ST 1902") await self._wait_ready() - async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[bool]=False) -> str: + async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[bool]=False) -> Optional[str]: """ Fetch a plate from the incubator to the loading tray.""" site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -194,13 +194,15 @@ async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[b if read_barcode: barcode = await self.read_barcode_inline(m,n) - print(barcode) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False) -> str: + if read_barcode: + return barcode + + async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False) -> Optional[str]: """ Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -218,7 +220,10 @@ async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Opt await self._send_command_plc("ST 1903") # terminate access - async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False): + if read_barcode: + return barcode + + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False) -> Optional[str]: """ Move plate from one internal position to another""" orig_site = plate.parent assert isinstance(orig_site, PlateHolder) @@ -241,7 +246,6 @@ async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, if read_barcode: barcode = await self.read_barcode_inline(orig_m,orig_n) - print(barcode) await self._send_command_plc("ST 1908") # pick plate from origin position @@ -257,6 +261,9 @@ async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access + if read_barcode: + return barcode + async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: if self.barcode_installed: await self._send_command_plc("ST 1910") # move shovel to barcode reading position From 85b21a5342726b9de510c24b66b2c3c9639fac04 Mon Sep 17 00:00:00 2001 From: Sam Burns Date: Wed, 28 Jan 2026 11:22:09 +0100 Subject: [PATCH 29/35] Updated documentation --- .../storage/liconic.ipynb | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb index bc7d91d3652..aa361977397 100644 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb @@ -116,32 +116,6 @@ "# await incubator.take_in_plate(rack[3]) # store at rack position 3" ] }, - { - "cell_type": "markdown", - "id": "d7cf08c2", - "metadata": {}, - "source": [ - "Barcode can returned " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b9cf2ed", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.move_position_to_position(plate_name=\"TEST\",dest_site=position,read_barcode=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2dc9057e", - "metadata": {}, - "outputs": [], - "source": [] - }, { "cell_type": "markdown", "id": "85dcddb7", @@ -166,7 +140,12 @@ "id": "0045e703", "metadata": {}, "source": [ - "You can also print a barcode from this call (if barcode is installed per the backend insatiation). Returning of the barcode as a return object still needs to be implemented. " + "You can also print a barcode from this call (if barcode is installed per the backend insatiation). Returning of the barcode as a return object still needs to be implemented. Currently the barcode is just printed to the terminal.\n", + "\n", + "Barcode can returned by setting the read_barcode to True for \n", + "- take_in_plate\n", + "- fetch_plate_to_loading_tray\n", + "- move_position_to_position" ] }, { @@ -176,7 +155,13 @@ "metadata": {}, "outputs": [], "source": [ + "position = rack[9][0] # rack number 9 position 1\n", + "\n", "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\",read_barcode=True)\n", + "\n", + "await incubator.take_in_plate(position,read_barcode=True)\n", + "\n", + "await incubator.move_position_to_position(plate_name=\"TEST\",dest_site=position,read_barcode=True)\n", "# will print the barcode to the terminal" ] }, From 6c18d8fca8152383586078002b5be014ac6a8c3c Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:28:41 -0800 Subject: [PATCH 30/35] format --- pylabrobot/barcode_scanners/backend.py | 16 +- .../keyence/barcode_scanner_backend.py | 61 +-- pylabrobot/storage/backend.py | 30 +- pylabrobot/storage/incubator.py | 48 ++- pylabrobot/storage/liconic/constants.py | 125 +++--- pylabrobot/storage/liconic/errors.py | 393 +++++++++++++----- pylabrobot/storage/liconic/liconic_backend.py | 196 +++++---- pylabrobot/storage/liconic/racks.py | 386 ++++++++++++++--- 8 files changed, 881 insertions(+), 374 deletions(-) diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/barcode_scanners/backend.py index 5f1a3758b57..43e83c27485 100644 --- a/pylabrobot/barcode_scanners/backend.py +++ b/pylabrobot/barcode_scanners/backend.py @@ -2,14 +2,16 @@ from pylabrobot.machines.backend import MachineBackend + class BarcodeScannerError(Exception): - """Error raised by a barcode scanner backend.""" + """Error raised by a barcode scanner backend.""" + class BarcodeScannerBackend(MachineBackend, metaclass=ABCMeta): - def __init__(self): - super().__init__() + def __init__(self): + super().__init__() - @abstractmethod - async def scan_barcode(self) -> str: - """Scan a barcode and return its value as a string.""" - pass + @abstractmethod + async def scan_barcode(self) -> str: + """Scan a barcode and return its value as a string.""" + pass diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index f5610ff8af0..32a56c63932 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -1,22 +1,26 @@ import asyncio +import time +from typing import Optional + +import serial + from pylabrobot.barcode_scanners.backend import ( BarcodeScannerBackend, BarcodeScannerError, ) - -import serial -import time - -from typing import Optional from pylabrobot.io.serial import Serial + class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): default_baudrate = 9600 serial_messaging_encoding = "ascii" init_timeout = 1.0 # seconds poll_interval = 0.2 # seconds - def __init__(self, serial_port: str,): + def __init__( + self, + serial_port: str, + ): super().__init__() # BL-1300 Barcode reader factory default serial communication settings @@ -51,8 +55,9 @@ async def initialize_scanner(self): raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.") await asyncio.sleep(self.poll_interval) else: - raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: " \ - "Timeout waiting for motor to turn on.") + raise BarcodeScannerError( + "Failed to initialize Keyence barcode scanner: " "Timeout waiting for motor to turn on." + ) async def send_command(self, command: str) -> str: """Send a command to the barcode scanner and return the response. @@ -67,31 +72,31 @@ async def send_command_and_stream( command: str, on_response: callable, timeout: float = 5.0, - stop_condition: Optional[callable] = None -): + stop_condition: Optional[callable] = None, + ): """Send a command and call on_response for each barcode response.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) deadline = time.time() + timeout while time.time() < deadline: - try: - response = await asyncio.wait_for(self.io.readline(), timeout=1.0) - if response: - decoded = response.decode(self.serial_messaging_encoding).strip() - print(f"Received from barcode scanner: {decoded}") - if decoded: - try: - await on_response(decoded) # Call the callback - except Exception as e: - print(f"Error in callback: {e}") - if stop_condition and stop_condition(decoded): - break - except asyncio.TimeoutError: - print("Barcode scanner timeout, continuing...") - continue - except Exception as e: - print(f"Error reading from barcode scanner: {e}") - continue + try: + response = await asyncio.wait_for(self.io.readline(), timeout=1.0) + if response: + decoded = response.decode(self.serial_messaging_encoding).strip() + print(f"Received from barcode scanner: {decoded}") + if decoded: + try: + await on_response(decoded) # Call the callback + except Exception as e: + print(f"Error in callback: {e}") + if stop_condition and stop_condition(decoded): + break + except asyncio.TimeoutError: + print("Barcode scanner timeout, continuing...") + continue + except Exception as e: + print(f"Error reading from barcode scanner: {e}") + continue async def stop(self): await self.io.stop() diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py index a1194b74dec..b74cb12e191 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/storage/backend.py @@ -55,72 +55,72 @@ async def stop_shaking(self): @abstractmethod async def get_set_temperature(self) -> float: - """ Get the set value temperature of the incubator in degrees Celsius.""" + """Get the set value temperature of the incubator in degrees Celsius.""" pass @abstractmethod async def set_humidity(self, humidity: float): - """ Set operation humidity of the incubator in % RH; e.g. 90.0% RH.""" + """Set operation humidity of the incubator in % RH; e.g. 90.0% RH.""" pass @abstractmethod async def get_humidity(self) -> float: - """ Get the current humidity of the incubator in % RH; e.g. 90.0% RH.""" + """Get the current humidity of the incubator in % RH; e.g. 90.0% RH.""" pass @abstractmethod async def get_set_humidity(self) -> float: - """ Get the set value humidity of the incubator in % RH; e.g. 90.0% RH.""" + """Get the set value humidity of the incubator in % RH; e.g. 90.0% RH.""" pass @abstractmethod async def set_co2_level(self, co2_level: float): - """ Set operation CO2 level of the incubator in %; e.g. 5.0%.""" + """Set operation CO2 level of the incubator in %; e.g. 5.0%.""" pass @abstractmethod async def get_co2_level(self) -> float: - """ Get the current CO2 level of the incubator in %; e.g. 5.0%.""" + """Get the current CO2 level of the incubator in %; e.g. 5.0%.""" pass @abstractmethod async def get_set_co2_level(self) -> float: - """ Get the set value CO2 level of the incubator in %; e.g. 5.0%.""" + """Get the set value CO2 level of the incubator in %; e.g. 5.0%.""" pass @abstractmethod async def set_n2_level(self, n2_level: float): - """ Set operation N2 level of the incubator in %; e.g. 90.0%.""" + """Set operation N2 level of the incubator in %; e.g. 90.0%.""" pass @abstractmethod async def get_n2_level(self) -> float: - """ Get the current N2 level of the incubator in %; e.g. 90.0%.""" + """Get the current N2 level of the incubator in %; e.g. 90.0%.""" pass @abstractmethod async def get_set_n2_level(self) -> float: - """ Get the set value N2 level of the incubator in %; e.g. 90.0%.""" + """Get the set value N2 level of the incubator in %; e.g. 90.0%.""" pass @abstractmethod async def turn_swap_station(self, home: bool): - """ Swap the incubator station to home or 180 degree position.""" + """Swap the incubator station to home or 180 degree position.""" pass @abstractmethod async def check_shovel_sensor(self) -> bool: - """ Check if there is a plate on the shovel plate sensor.""" + """Check if there is a plate on the shovel plate sensor.""" pass @abstractmethod async def check_transfer_sensor(self) -> bool: - """ Check if there is a plate on the transfer sensor.""" + """Check if there is a plate on the transfer sensor.""" pass @abstractmethod async def check_second_transfer_sensor(self) -> bool: - """ Check 2nd transfer station plate sensor.""" + """Check 2nd transfer station plate sensor.""" pass @abstractmethod @@ -130,5 +130,5 @@ async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): @abstractmethod async def move_position_to_position(self, plate_name: str, dest_site: PlateHolder): - """ Move plate by name to another position in the storage unit""" + """Move plate by name to another position in the storage unit""" pass diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index e35b23dff41..b19a48a264c 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -73,7 +73,9 @@ def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: return site raise ResourceNotFoundError(f"Plate {plate_name} not found in incubator '{self.name}'") - async def fetch_plate_to_loading_tray(self, plate_name: str, read_barcode: Optional[bool]=False) -> Plate: + async def fetch_plate_to_loading_tray( + self, plate_name: str, read_barcode: Optional[bool] = False + ) -> Plate: """Fetch a plate from the incubator and put it on the loading tray.""" site = self.get_site_by_plate_name(plate_name) @@ -119,7 +121,11 @@ def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: def find_random_site(self, plate: Plate) -> PlateHolder: return random.choice(self._find_available_sites_sorted(plate)) - async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]], read_barcode: Optional[bool] = False): + async def take_in_plate( + self, + site: Union[PlateHolder, Literal["random", "smallest"]], + read_barcode: Optional[bool] = False, + ): """Take a plate from the loading tray and put it in the incubator.""" plate = cast(Plate, self.loading_tray.resource) @@ -139,7 +145,7 @@ async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smalle if read_barcode: barcode = await self.backend.take_in_plate(plate, site, read_barcode) print(barcode) - # undecided with what we want to do with barcode string (no Plate variable for it) + # undecided with what we want to do with barcode string (no Plate variable for it) else: await self.backend.take_in_plate(plate, site) @@ -228,63 +234,65 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): """ Methods added for Liconic incubator options.""" async def get_set_temperature(self) -> float: - """ Get the set value temperature of the incubator in degrees Celsius.""" + """Get the set value temperature of the incubator in degrees Celsius.""" return await self.backend.get_set_temperature() async def set_humidity(self, humidity: float): - """ Set the humidity of the incubator in percentage (%).""" + """Set the humidity of the incubator in percentage (%).""" return await self.backend.set_humidity(humidity) async def get_humidity(self) -> float: - """ Get the humidity of the incubator in percentage (%).""" + """Get the humidity of the incubator in percentage (%).""" return await self.backend.get_humidity() async def get_set_humidity(self) -> float: - """ Get the set value humidity of the incubator in percentage (%).""" + """Get the set value humidity of the incubator in percentage (%).""" return await self.backend.get_set_humidity() async def set_co2_level(self, co2_level: float): - """ Set the CO2 level of the incubator in percentage (%).""" + """Set the CO2 level of the incubator in percentage (%).""" return await self.backend.set_co2_level(co2_level) async def get_co2_level(self) -> float: - """ Get the CO2 level of the incubator in percentage (%).""" + """Get the CO2 level of the incubator in percentage (%).""" return await self.backend.get_co2_level() async def get_set_co2_level(self) -> float: - """ Get the set value CO2 level of the incubator in percentage (%).""" + """Get the set value CO2 level of the incubator in percentage (%).""" return await self.backend.get_set_co2_level() async def set_n2_level(self, n2_level: float): - """ Set the N2 level of the incubator in percentage (%).""" + """Set the N2 level of the incubator in percentage (%).""" return await self.backend.set_n2_level(n2_level) async def get_n2_level(self) -> float: - """ Get the N2 level of the incubator in percentage (%).""" + """Get the N2 level of the incubator in percentage (%).""" return await self.backend.get_n2_level() async def get_set_n2_level(self) -> float: - """ Get the set value N2 level of the incubator in percentage (%).""" + """Get the set value N2 level of the incubator in percentage (%).""" return await self.backend.get_set_n2_level() async def turn_swap_station(self, home: bool): - """ Turn the swap station of the incubator. If home is True, turn to home position.""" + """Turn the swap station of the incubator. If home is True, turn to home position.""" return await self.backend.turn_swap_station(home) async def check_shovel_sensor(self) -> bool: - """ Check if the shovel plate sensor is activated.""" + """Check if the shovel plate sensor is activated.""" return await self.backend.check_shovel_sensor() async def check_transfer_sensor(self) -> bool: - """ Check if the transfer plate sensor is activated.""" + """Check if the transfer plate sensor is activated.""" return await self.backend.check_transfer_sensor() async def check_second_transfer_sensor(self) -> bool: - """ Check if the second transfer plate sensor is activated.""" + """Check if the second transfer plate sensor is activated.""" return await self.backend.check_second_transfer_sensor() - async def move_position_to_position(self, plate_name: str, dest_site: PlateHolder, read_barcode: Optional[bool]=False) -> Plate: - """ Move a plate to another internal position in the storage unit """ + async def move_position_to_position( + self, plate_name: str, dest_site: PlateHolder, read_barcode: Optional[bool] = False + ) -> Plate: + """Move a plate to another internal position in the storage unit""" site = self.get_site_by_plate_name(plate_name) plate = site.resource assert plate is not None @@ -294,7 +302,7 @@ async def move_position_to_position(self, plate_name: str, dest_site: PlateHolde print(barcode) # undecided with what we want to do with barcode string (no Plate variable for it) else: - await self.backend.move_position_to_position(plate,dest_site) + await self.backend.move_position_to_position(plate, dest_site) plate.unassign() site.assign_child_resource(plate) diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py index 691fef03cbd..73617096d96 100644 --- a/pylabrobot/storage/liconic/constants.py +++ b/pylabrobot/storage/liconic/constants.py @@ -1,65 +1,67 @@ from enum import Enum, IntEnum + class LiconicType(Enum): - STX44_IC = "STX44_IC" # incubator - STX44_HC = "STX44_HC" # humid cooler - STX44_DC2 = "STX44_DC2" # dry storage - STX44_HR = "STX44_HR" # humid wide range - STX44_DR2 = "STX44_DR2" # dry wide range - STX44_AR = "STX44_AR" # humidity controlled - STX44_DF = "STX44_DF" # deep freezer - STX44_NC = "STX44_NC" # no climate - STX44_DH = "STX44_DH" # dry humid - - STX110_IC = "STX110_IC" # incubator - STX110_HC = "STX110_HC" # humid cooler - STX110_DC2 = "STX110_DC2" # dry storage - STX110_HR = "STX110_HR" # humid wide range - STX110_DR2 = "STX110_DR2" # dry wide range - STX110_AR = "STX110_AR" # humidity controlled - STX110_DF = "STX110_DF" # deep freezer - STX110_NC = "STX110_NC" # no climate - STX110_DH = "STX110_DH" # dry humid - - STX220_IC = "STX220_IC" # incubator - STX220_HC = "STX220_HC" # humid cooler - STX220_DC2 = "STX220_DC2" # dry storage - STX220_HR = "STX220_HR" # humid wide range - STX220_DR2 = "STX220_DR2" # dry wide range - STX220_AR = "STX220_AR" # humidity controlled - STX220_DF = "STX220_DF" # deep freezer - STX220_NC = "STX220_NC" # no climate - STX220_DH = "STX220_DH" # dry humid - - STX280_IC = "STX280_IC" # incubator - STX280_HC = "STX280_HC" # humid cooler - STX280_DC2 = "STX280_DC2" # dry storage - STX280_HR = "STX280_HR" # humid wide range - STX280_DR2 = "STX280_DR2" # dry wide range - STX280_AR = "STX280_AR" # humidity controlled - STX280_DF = "STX280_DF" # deep freezer - STX280_NC = "STX280_NC" # no climate - STX280_DH = "STX44_DH" # dry humid - - STX500_IC = "STX500_IC" # incubator - STX500_HC = "STX500_HC" # humid cooler - STX500_DC2 = "STX500_DC2" # dry storage - STX500_HR = "STX500_HR" # humid wide range - STX500_DR2 = "STX500_DR2" # dry wide range - STX500_AR = "STX500_AR" # humidity controlled - STX500_DF = "STX500_DF" # deep freezer - STX500_NC = "STX500_NC" # no climate - STX500_DH = "STX500_DH" # dry humid - - STX1000_IC = "STX1000_IC" # incubator - STX1000_HC = "STX1000_HC" # humid cooler - STX1000_DC2 = "STX1000_DC2" # dry storage - STX1000_HR = "STX1000_HR" # humid wide range - STX1000_DR2 = "STX1000_DR2" # dry wide range - STX1000_AR = "STX1000_AR" # humidity controlled - STX1000_DF = "STX1000_DF" # deep freezer - STX1000_NC = "STX1000_NC" # no climate - STX1000_DH = "STX1000_DH" # dry humid + STX44_IC = "STX44_IC" # incubator + STX44_HC = "STX44_HC" # humid cooler + STX44_DC2 = "STX44_DC2" # dry storage + STX44_HR = "STX44_HR" # humid wide range + STX44_DR2 = "STX44_DR2" # dry wide range + STX44_AR = "STX44_AR" # humidity controlled + STX44_DF = "STX44_DF" # deep freezer + STX44_NC = "STX44_NC" # no climate + STX44_DH = "STX44_DH" # dry humid + + STX110_IC = "STX110_IC" # incubator + STX110_HC = "STX110_HC" # humid cooler + STX110_DC2 = "STX110_DC2" # dry storage + STX110_HR = "STX110_HR" # humid wide range + STX110_DR2 = "STX110_DR2" # dry wide range + STX110_AR = "STX110_AR" # humidity controlled + STX110_DF = "STX110_DF" # deep freezer + STX110_NC = "STX110_NC" # no climate + STX110_DH = "STX110_DH" # dry humid + + STX220_IC = "STX220_IC" # incubator + STX220_HC = "STX220_HC" # humid cooler + STX220_DC2 = "STX220_DC2" # dry storage + STX220_HR = "STX220_HR" # humid wide range + STX220_DR2 = "STX220_DR2" # dry wide range + STX220_AR = "STX220_AR" # humidity controlled + STX220_DF = "STX220_DF" # deep freezer + STX220_NC = "STX220_NC" # no climate + STX220_DH = "STX220_DH" # dry humid + + STX280_IC = "STX280_IC" # incubator + STX280_HC = "STX280_HC" # humid cooler + STX280_DC2 = "STX280_DC2" # dry storage + STX280_HR = "STX280_HR" # humid wide range + STX280_DR2 = "STX280_DR2" # dry wide range + STX280_AR = "STX280_AR" # humidity controlled + STX280_DF = "STX280_DF" # deep freezer + STX280_NC = "STX280_NC" # no climate + STX280_DH = "STX44_DH" # dry humid + + STX500_IC = "STX500_IC" # incubator + STX500_HC = "STX500_HC" # humid cooler + STX500_DC2 = "STX500_DC2" # dry storage + STX500_HR = "STX500_HR" # humid wide range + STX500_DR2 = "STX500_DR2" # dry wide range + STX500_AR = "STX500_AR" # humidity controlled + STX500_DF = "STX500_DF" # deep freezer + STX500_NC = "STX500_NC" # no climate + STX500_DH = "STX500_DH" # dry humid + + STX1000_IC = "STX1000_IC" # incubator + STX1000_HC = "STX1000_HC" # humid cooler + STX1000_DC2 = "STX1000_DC2" # dry storage + STX1000_HR = "STX1000_HR" # humid wide range + STX1000_DR2 = "STX1000_DR2" # dry wide range + STX1000_AR = "STX1000_AR" # humidity controlled + STX1000_DF = "STX1000_DF" # deep freezer + STX1000_NC = "STX1000_NC" # no climate + STX1000_DH = "STX1000_DH" # dry humid + class ControllerError(Enum): RELAY_ERROR = "E0" @@ -69,6 +71,7 @@ class ControllerError(Enum): WRITE_PROTECTED_ERROR = "E4" BASE_UNIT_ERROR = "E5" + class HandlingError(Enum): GENERAL_HANDLING_ERROR = "00001" GATE_OPEN_ERROR = "00007" @@ -83,7 +86,7 @@ class HandlingError(Enum): NO_PLATE_ON_SHOVEL_DETECTION = "00016" NO_RECOVERY = "00017" - IMPORT_PLATE_STACKER_POSITIONING_ERROR = "00100" + IMPORT_PLATE_STACKER_POSITIONING_ERROR = "00100" IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR = "00101" IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR = "00102" IMPORT_PLATE_LIFT_TRANSFER_ERROR = "00103" @@ -104,7 +107,7 @@ class HandlingError(Enum): EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR = "00205" EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR = "00206" EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR = "00207" - EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR= "00208" + EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR = "00208" EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR = "00209" EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR = "00210" EXPORT_PLATE_LIFT_INITIALIZING_ERROR = "00211" diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/storage/liconic/errors.py index 4b5d342d6fa..03e65894d66 100644 --- a/pylabrobot/storage/liconic/errors.py +++ b/pylabrobot/storage/liconic/errors.py @@ -2,24 +2,31 @@ from pylabrobot.storage.liconic.constants import ControllerError, HandlingError + class LiconicControllerRelayError(Exception): pass + class LiconicControllerCommandError(Exception): pass + class LiconicControllerProgramError(Exception): pass + class LiconicControllerHardwareError(Exception): pass + class LiconicControllerWriteProtectedError(Exception): pass + class LiconicControllerBaseUnitError(Exception): pass + controller_error_map: Dict[ControllerError, Exception] = { ControllerError.RELAY_ERROR: LiconicControllerRelayError( "Controller system error. Undefined timer, counter, data memory, check if requested unit is valid" @@ -38,135 +45,321 @@ class LiconicControllerBaseUnitError(Exception): ), ControllerError.BASE_UNIT_ERROR: LiconicControllerBaseUnitError( "Controller system error. Unauthorized Access" - ) + ), } + class LiconicHandlerPlateRemoveError(Exception): pass + class LiconicHandlerBarcodeReadError(Exception): pass + class LiconicHandlerPlatePlaceError(Exception): pass + class LiconicHandlerPlateSetError(Exception): pass + class LiconicHandlerPlateGetError(Exception): pass + class LiconicHandlerImportPlateError(Exception): pass + class LiconicHandlerExportPlateError(Exception): pass + class LiconicHandlerGeneralError(Exception): pass + handler_error_map: Dict[HandlingError, Exception] = { - HandlingError.GENERAL_HANDLING_ERROR: LiconicHandlerGeneralError("Handling action could not be performed in time"), - HandlingError.GATE_OPEN_ERROR: LiconicHandlerGeneralError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.GATE_CLOSE_ERROR: LiconicHandlerGeneralError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerGeneralError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.USER_ACCESS_ERROR: LiconicHandlerGeneralError("Unauthorized user access in combination with manual rotation of carrousel"), + HandlingError.GENERAL_HANDLING_ERROR: LiconicHandlerGeneralError( + "Handling action could not be performed in time" + ), + HandlingError.GATE_OPEN_ERROR: LiconicHandlerGeneralError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.GATE_CLOSE_ERROR: LiconicHandlerGeneralError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerGeneralError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.USER_ACCESS_ERROR: LiconicHandlerGeneralError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), HandlingError.STACKER_SLOT_ERROR: LiconicHandlerGeneralError("Stacker slot cannot be reached "), - HandlingError.REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerGeneralError("Undefined stacker level has been requested"), - HandlingError.PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerGeneralError("Export operation while plate is on transfer station"), - HandlingError.LIFT_INITIALIZATION_ERROR: LiconicHandlerGeneralError("Lift could not be initialized "), - HandlingError.PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerGeneralError( + "Undefined stacker level has been requested" + ), + HandlingError.PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerGeneralError( + "Export operation while plate is on transfer station" + ), + HandlingError.LIFT_INITIALIZATION_ERROR: LiconicHandlerGeneralError( + "Lift could not be initialized " + ), + HandlingError.PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError( + "Trying to remove or place plate with no plate on the shovel" + ), HandlingError.NO_RECOVERY: LiconicHandlerGeneralError("Recovery was not possible "), - - HandlingError.IMPORT_PLATE_STACKER_POSITIONING_ERROR: LiconicHandlerImportPlateError("Carrousel could not reach desired radial position during Import Plate procedure or Lift could not reach transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerImportPlateError("Handler could not reach outer turn position at transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach outer position at transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_LIFT_TRANSFER_ERROR: LiconicHandlerImportPlateError("Lift did not reach upper pick position at transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach inner position at transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerImportPlateError("Handler could not reach inner turn position at transfer level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerImportPlateError("Lift could not reach desired stacker level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerImportPlateError("Shovel could not reach front position on stacker access during Plate Import procedure."), - HandlingError.IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR: LiconicHandlerImportPlateError("Lift could not reach stacker place level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerImportPlateError("Shovel could not reach inner position at stacker plate placement during Import Plate procedure."), - HandlingError.IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerImportPlateError("Lift could not reach zero level during Import Plate procedure."), - HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: LiconicHandlerImportPlateError("Lift could not be initialized after Import Plate procedure."), - - HandlingError.EXPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerExportPlateError("Carrousel could not reach desired radial position during Export Plate procedure or Lift could not reach desired stacker level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerExportPlateError("Shovel could not reach front position on stacker access during Plate Export procedure."), - HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: LiconicHandlerExportPlateError("Lift could not reach stacker pick level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach inner position at stacker plate pick during Export Plate procedure."), - HandlingError.EXPORT_PLATE_LIFT_TRANSFER_POSITIONING_ERROR: LiconicHandlerExportPlateError("Lift could not reach transfer level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerExportPlateError("Handler could not reach outer turn position at transfer level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach outer position at transfer level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR: LiconicHandlerExportPlateError("Lift did not reach lower place position at transfer level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerExportPlateError("Shovel could not reach inner position at transfer level during Export Plate procedure."), - HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerExportPlateError("Handler could not reach inner turn position at transfer level during Export Plate procedure"), - HandlingError.EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerExportPlateError("Lift could not reach Zero position during Export Plate procedure."), - HandlingError.EXPORT_PLATE_LIFT_INITIALIZING_ERROR: LiconicHandlerExportPlateError("Lift could not be initialized after Export Plate procedure."), - - HandlingError.PLATE_REMOVE_GENERAL_HANDLING_ERROR: LiconicHandlerPlateRemoveError("Handling action could not be performed in time."), - HandlingError.PLATE_REMOVE_GATE_OPEN_ERROR: LiconicHandlerPlateRemoveError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.PLATE_REMOVE_GATE_CLOSE_ERROR: LiconicHandlerPlateRemoveError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateRemoveError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: LiconicHandlerPlateRemoveError("Unauthorized user access in combination with manual rotation of carrousel"), - HandlingError.PLATE_REMOVE_STACKER_SLOT_ERROR: LiconicHandlerPlateRemoveError("Stacker slot cannot be reached"), - HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateRemoveError("Undefined stacker level has been requested"), - HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateRemoveError("Export operation while plate is on transfer station"), - HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateRemoveError("Lift could not be initialized"), - HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError("Trying to remove or place plate with no plate on the shovel"), - HandlingError.PLATE_REMOVE_NO_RECOVERY: LiconicHandlerPlateRemoveError("Recovery was not possible"), - - HandlingError.BARCODE_READ_GENERAL_HANDLING_ERROR: LiconicHandlerBarcodeReadError("Handling action could not be performed in time."), - HandlingError.BARCODE_READ_GATE_OPEN_ERROR: LiconicHandlerBarcodeReadError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.BARCODE_READ_GATE_CLOSE_ERROR: LiconicHandlerBarcodeReadError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerBarcodeReadError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.BARCODE_READ_USER_ACCESS_ERROR: LiconicHandlerBarcodeReadError("Unauthorized user access in combination with manual rotation of carrousel"), - HandlingError.BARCODE_READ_STACKER_SLOT_ERROR: LiconicHandlerBarcodeReadError("Stacker slot cannot be reached"), - HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerBarcodeReadError("Undefined stacker level has been requested"), - HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerBarcodeReadError("Export operation while plate is on transfer station"), - HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: LiconicHandlerBarcodeReadError("Lift could not be initialized"), - HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError("Trying to remove or place plate with no plate on the shovel"), - HandlingError.BARCODE_READ_NO_RECOVERY: LiconicHandlerBarcodeReadError("Recovery was not possible"), - - HandlingError.PLATE_PLACE_GENERAL_HANDLING_ERROR: LiconicHandlerPlatePlaceError("Handling action could not be performed in time."), - HandlingError.PLATE_PLACE_GATE_OPEN_ERROR: LiconicHandlerPlatePlaceError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.PLATE_PLACE_GATE_CLOSE_ERROR: LiconicHandlerPlatePlaceError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlatePlaceError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: LiconicHandlerPlatePlaceError("Unauthorized user access in combination with manual rotation of carrousel"), - HandlingError.PLATE_PLACE_STACKER_SLOT_ERROR: LiconicHandlerPlatePlaceError("Stacker slot cannot be reached"), - HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlatePlaceError("Undefined stacker level has been requested"), - HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlatePlaceError("Export operation while plate is on transfer station"), - HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlatePlaceError("Lift could not be initialized"), - HandlingError.PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.IMPORT_PLATE_STACKER_POSITIONING_ERROR: LiconicHandlerImportPlateError( + "Carrousel could not reach desired radial position during Import Plate procedure or Lift could not reach transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerImportPlateError( + "Handler could not reach outer turn position at transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerImportPlateError( + "Shovel could not reach outer position at transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_LIFT_TRANSFER_ERROR: LiconicHandlerImportPlateError( + "Lift did not reach upper pick position at transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerImportPlateError( + "Shovel could not reach inner position at transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerImportPlateError( + "Handler could not reach inner turn position at transfer level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerImportPlateError( + "Lift could not reach desired stacker level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerImportPlateError( + "Shovel could not reach front position on stacker access during Plate Import procedure." + ), + HandlingError.IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR: LiconicHandlerImportPlateError( + "Lift could not reach stacker place level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerImportPlateError( + "Shovel could not reach inner position at stacker plate placement during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerImportPlateError( + "Lift could not reach zero level during Import Plate procedure." + ), + HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: LiconicHandlerImportPlateError( + "Lift could not be initialized after Import Plate procedure." + ), + HandlingError.EXPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerExportPlateError( + "Carrousel could not reach desired radial position during Export Plate procedure or Lift could not reach desired stacker level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerExportPlateError( + "Shovel could not reach front position on stacker access during Plate Export procedure." + ), + HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: LiconicHandlerExportPlateError( + "Lift could not reach stacker pick level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: LiconicHandlerExportPlateError( + "Shovel could not reach inner position at stacker plate pick during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_POSITIONING_ERROR: LiconicHandlerExportPlateError( + "Lift could not reach transfer level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerExportPlateError( + "Handler could not reach outer turn position at transfer level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerExportPlateError( + "Shovel could not reach outer position at transfer level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR: LiconicHandlerExportPlateError( + "Lift did not reach lower place position at transfer level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: LiconicHandlerExportPlateError( + "Shovel could not reach inner position at transfer level during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerExportPlateError( + "Handler could not reach inner turn position at transfer level during Export Plate procedure" + ), + HandlingError.EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerExportPlateError( + "Lift could not reach Zero position during Export Plate procedure." + ), + HandlingError.EXPORT_PLATE_LIFT_INITIALIZING_ERROR: LiconicHandlerExportPlateError( + "Lift could not be initialized after Export Plate procedure." + ), + HandlingError.PLATE_REMOVE_GENERAL_HANDLING_ERROR: LiconicHandlerPlateRemoveError( + "Handling action could not be performed in time." + ), + HandlingError.PLATE_REMOVE_GATE_OPEN_ERROR: LiconicHandlerPlateRemoveError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.PLATE_REMOVE_GATE_CLOSE_ERROR: LiconicHandlerPlateRemoveError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateRemoveError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: LiconicHandlerPlateRemoveError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), + HandlingError.PLATE_REMOVE_STACKER_SLOT_ERROR: LiconicHandlerPlateRemoveError( + "Stacker slot cannot be reached" + ), + HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateRemoveError( + "Undefined stacker level has been requested" + ), + HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateRemoveError( + "Export operation while plate is on transfer station" + ), + HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateRemoveError( + "Lift could not be initialized" + ), + HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError( + "Trying to remove or place plate with no plate on the shovel" + ), + HandlingError.PLATE_REMOVE_NO_RECOVERY: LiconicHandlerPlateRemoveError( + "Recovery was not possible" + ), + HandlingError.BARCODE_READ_GENERAL_HANDLING_ERROR: LiconicHandlerBarcodeReadError( + "Handling action could not be performed in time." + ), + HandlingError.BARCODE_READ_GATE_OPEN_ERROR: LiconicHandlerBarcodeReadError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.BARCODE_READ_GATE_CLOSE_ERROR: LiconicHandlerBarcodeReadError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerBarcodeReadError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.BARCODE_READ_USER_ACCESS_ERROR: LiconicHandlerBarcodeReadError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), + HandlingError.BARCODE_READ_STACKER_SLOT_ERROR: LiconicHandlerBarcodeReadError( + "Stacker slot cannot be reached" + ), + HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerBarcodeReadError( + "Undefined stacker level has been requested" + ), + HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerBarcodeReadError( + "Export operation while plate is on transfer station" + ), + HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: LiconicHandlerBarcodeReadError( + "Lift could not be initialized" + ), + HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError( + "Trying to remove or place plate with no plate on the shovel" + ), + HandlingError.BARCODE_READ_NO_RECOVERY: LiconicHandlerBarcodeReadError( + "Recovery was not possible" + ), + HandlingError.PLATE_PLACE_GENERAL_HANDLING_ERROR: LiconicHandlerPlatePlaceError( + "Handling action could not be performed in time." + ), + HandlingError.PLATE_PLACE_GATE_OPEN_ERROR: LiconicHandlerPlatePlaceError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.PLATE_PLACE_GATE_CLOSE_ERROR: LiconicHandlerPlatePlaceError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlatePlaceError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: LiconicHandlerPlatePlaceError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), + HandlingError.PLATE_PLACE_STACKER_SLOT_ERROR: LiconicHandlerPlatePlaceError( + "Stacker slot cannot be reached" + ), + HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlatePlaceError( + "Undefined stacker level has been requested" + ), + HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlatePlaceError( + "Export operation while plate is on transfer station" + ), + HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlatePlaceError( + "Lift could not be initialized" + ), + HandlingError.PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError( + "Trying to remove or place plate with no plate on the shovel" + ), HandlingError.PLATE_PLACE_NO_RECOVERY: LiconicHandlerPlatePlaceError("Recovery was not possible"), - - HandlingError.PLATE_SET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateSetError("Handling action could not be performed in time."), - HandlingError.PLATE_SET_GATE_OPEN_ERROR: LiconicHandlerPlateSetError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.PLATE_SET_GATE_CLOSE_ERROR: LiconicHandlerPlateSetError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateSetError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.PLATE_SET_USER_ACCESS_ERROR: LiconicHandlerPlateSetError("Unauthorized user access in combination with manual rotation of carrousel"), - HandlingError.PLATE_SET_STACKER_SLOT_ERROR: LiconicHandlerPlateSetError("Stacker slot cannot be reached"), - HandlingError.PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateSetError("Undefined stacker level has been requested"), - HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateSetError("Export operation while plate is on transfer station"), - HandlingError.PLATE_SET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateSetError("Lift could not be initialized"), - HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError("Trying to remove or place plate with no plate on the shovel"), + HandlingError.PLATE_SET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateSetError( + "Handling action could not be performed in time." + ), + HandlingError.PLATE_SET_GATE_OPEN_ERROR: LiconicHandlerPlateSetError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.PLATE_SET_GATE_CLOSE_ERROR: LiconicHandlerPlateSetError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateSetError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.PLATE_SET_USER_ACCESS_ERROR: LiconicHandlerPlateSetError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), + HandlingError.PLATE_SET_STACKER_SLOT_ERROR: LiconicHandlerPlateSetError( + "Stacker slot cannot be reached" + ), + HandlingError.PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateSetError( + "Undefined stacker level has been requested" + ), + HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateSetError( + "Export operation while plate is on transfer station" + ), + HandlingError.PLATE_SET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateSetError( + "Lift could not be initialized" + ), + HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError( + "Trying to remove or place plate with no plate on the shovel" + ), HandlingError.PLATE_SET_NO_RECOVERY: LiconicHandlerPlateSetError("Recovery was not possible"), - - HandlingError.PLATE_GET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateGetError("Handling action could not be performed in time."), - HandlingError.PLATE_GET_GATE_OPEN_ERROR: LiconicHandlerPlateGetError("Gate could not reach upper position or Gate did not reach upper position in time"), - HandlingError.PLATE_GET_GATE_CLOSE_ERROR: LiconicHandlerPlateGetError("Gate could not reach lower position or Gate did not reach lower position in time"), - HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateGetError("Handler-Lift could not reach desired level position or does not move"), - HandlingError.PLATE_GET_USER_ACCESS_ERROR: LiconicHandlerPlateGetError("Unauthorized user access in combination with manual rotation of carrousel"), - HandlingError.PLATE_GET_STACKER_SLOT_ERROR: LiconicHandlerPlateGetError("Stacker slot cannot be reached"), - HandlingError.PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateGetError("Undefined stacker level has been requested"), - HandlingError.PLATE_GET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateGetError("Export operation while plate is on transfer station"), - HandlingError.PLATE_GET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateGetError("Lift could not be initialized"), - HandlingError.PLATE_GET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError("Trying to load a plate, when a plate is already on the shovel"), - HandlingError.PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError("Trying to remove or place plate with no plate on the shovel"), - HandlingError.PLATE_GET_NO_RECOVERY: LiconicHandlerPlateGetError("Recovery was not possible during get plate") + HandlingError.PLATE_GET_GENERAL_HANDLING_ERROR: LiconicHandlerPlateGetError( + "Handling action could not be performed in time." + ), + HandlingError.PLATE_GET_GATE_OPEN_ERROR: LiconicHandlerPlateGetError( + "Gate could not reach upper position or Gate did not reach upper position in time" + ), + HandlingError.PLATE_GET_GATE_CLOSE_ERROR: LiconicHandlerPlateGetError( + "Gate could not reach lower position or Gate did not reach lower position in time" + ), + HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateGetError( + "Handler-Lift could not reach desired level position or does not move" + ), + HandlingError.PLATE_GET_USER_ACCESS_ERROR: LiconicHandlerPlateGetError( + "Unauthorized user access in combination with manual rotation of carrousel" + ), + HandlingError.PLATE_GET_STACKER_SLOT_ERROR: LiconicHandlerPlateGetError( + "Stacker slot cannot be reached" + ), + HandlingError.PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateGetError( + "Undefined stacker level has been requested" + ), + HandlingError.PLATE_GET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateGetError( + "Export operation while plate is on transfer station" + ), + HandlingError.PLATE_GET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateGetError( + "Lift could not be initialized" + ), + HandlingError.PLATE_GET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError( + "Trying to load a plate, when a plate is already on the shovel" + ), + HandlingError.PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError( + "Trying to remove or place plate with no plate on the shovel" + ), + HandlingError.PLATE_GET_NO_RECOVERY: LiconicHandlerPlateGetError( + "Recovery was not possible during get plate" + ), } diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 7bda2e4b8b0..22ac3ddffc8 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -1,37 +1,36 @@ import asyncio import logging +import re import time import warnings -from typing import List, Tuple, Optional, Union -import re +from typing import List, Optional, Tuple, Union import serial +from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend -from pylabrobot.storage.liconic.constants import LiconicType, ControllerError, HandlingError - +from pylabrobot.storage.liconic.constants import ControllerError, HandlingError, LiconicType from pylabrobot.storage.liconic.errors import controller_error_map, handler_error_map logger = logging.getLogger(__name__) # Mapping site_height to motor steps for Liconic cassettes LICONIC_SITE_HEIGHT_TO_STEPS = { - 5: 377, # pitch=11, site_height=5 - 11: 582, # pitch=17, site_height=11 - 12: 617, # pitch=18, site_height=12 - 17: 788, # pitch=23, site_height=17 - 22: 959, # pitch=28, site_height=22 - 23: 994, # pitch=29, site_height=23 - 24: 1028, # pitch=30, site_height=24 - 27: 1131, # pitch=33, site_height=27 - 44: 1713, # pitch=50, site_height=44 - 53: 2021, # pitch=59, site_height=53 - 66: 2467, # pitch=72, site_height=66 - 104: 3563 # pitch=110, site_height=104 + 5: 377, # pitch=11, site_height=5 + 11: 582, # pitch=17, site_height=11 + 12: 617, # pitch=18, site_height=12 + 17: 788, # pitch=23, site_height=17 + 22: 959, # pitch=28, site_height=22 + 23: 994, # pitch=29, site_height=23 + 24: 1028, # pitch=30, site_height=24 + 27: 1131, # pitch=33, site_height=27 + 44: 1713, # pitch=50, site_height=44 + 53: 2021, # pitch=59, site_height=53 + 66: 2467, # pitch=72, site_height=66 + 104: 3563, # pitch=110, site_height=104 } @@ -48,7 +47,13 @@ class LiconicBackend(IncubatorBackend): start_timeout = 15.0 poll_interval = 0.2 - def __init__(self, model: Union[LiconicType, str], port: str, barcode_installed: Optional[bool] = None, barcode_port: Optional[str] = None): + def __init__( + self, + model: Union[LiconicType, str], + port: str, + barcode_installed: Optional[bool] = None, + barcode_port: Optional[str] = None, + ): super().__init__() self.barcode_installed: Optional[bool] = barcode_installed @@ -150,12 +155,14 @@ def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: assert self._racks is not None, "Racks not set" if not rack.model.startswith("liconic"): raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") - match = re.search(r'_(\d+)mm', rack.model) + match = re.search(r"_(\d+)mm", rack.model) if match: site_height = int(match.group(1)) - site_num = int(rack.model.split('_')[-1]) + site_num = int(rack.model.split("_")[-1]) return LICONIC_SITE_HEIGHT_TO_STEPS.get(site_height), site_num - raise ValueError(f"Could not parse site height and pos num from PlateCarrier model: {rack.model}") + raise ValueError( + f"Could not parse site height and pos num from PlateCarrier model: {rack.model}" + ) async def stop(self): await self.io_plc.stop() @@ -179,21 +186,23 @@ async def close_door(self): await self._send_command_plc("ST 1902") await self._wait_ready() - async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[bool]=False) -> Optional[str]: - """ Fetch a plate from the incubator to the loading tray.""" + async def fetch_plate_to_loading_tray( + self, plate: str, read_barcode: Optional[bool] = False + ) -> Optional[str]: + """Fetch a plate from the incubator to the loading tray.""" site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command_plc(f"WR DM0 {m}") # carousel number - await self._send_command_plc(f"WR DM23 {step_size}") # motor step size - await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette + await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel if read_barcode: - barcode = await self.read_barcode_inline(m,n) + barcode = await self.read_barcode_inline(m, n) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() @@ -202,20 +211,22 @@ async def fetch_plate_to_loading_tray(self, plate: str, read_barcode: Optional[b if read_barcode: return barcode - async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool]=False) -> Optional[str]: - """ Take in a plate from the loading tray to the incubator.""" + async def take_in_plate( + self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool] = False + ) -> Optional[str]: + """Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command_plc(f"WR DM0 {m}") # carousel number - await self._send_command_plc(f"WR DM23 {step_size}") # motor step size - await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {pos_num}") # number of positions in cassette + await self._send_command_plc(f"WR DM5 {n}") # plate position in cassette await self._send_command_plc("ST 1904") # plate from transfer station await self._wait_ready() if read_barcode: - barcode = await self.read_barcode_inline(m,n) + barcode = await self.read_barcode_inline(m, n) print(barcode) await self._send_command_plc("ST 1903") # terminate access @@ -223,8 +234,10 @@ async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: Opt if read_barcode: return barcode - async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool]=False) -> Optional[str]: - """ Move plate from one internal position to another""" + async def move_position_to_position( + self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool] = False + ) -> Optional[str]: + """Move plate from one internal position to another""" orig_site = plate.parent assert isinstance(orig_site, PlateHolder) assert isinstance(dest_site, PlateHolder) @@ -232,34 +245,34 @@ async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder, if dest_site.resource is not None: raise RuntimeError(f"Position {dest_site} already has a plate assigned!") - orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # - dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # + orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # + dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # - await self._send_command_plc(f"WR DM0 {orig_m}") # origin cassette # + await self._send_command_plc(f"WR DM0 {orig_m}") # origin cassette # orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) - await self._send_command_plc(f"WR DM0 {orig_m}") # carousel number - await self._send_command_plc(f"WR DM23 {orig_step_size}") # motor step size - await self._send_command_plc(f"WR DM25 {orig_pos_num}") # number of positions in cassette - await self._send_command_plc(f"WR DM5 {orig_n}") # origin plate position # + await self._send_command_plc(f"WR DM0 {orig_m}") # carousel number + await self._send_command_plc(f"WR DM23 {orig_step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {orig_pos_num}") # number of positions in cassette + await self._send_command_plc(f"WR DM5 {orig_n}") # origin plate position # if read_barcode: - barcode = await self.read_barcode_inline(orig_m,orig_n) + barcode = await self.read_barcode_inline(orig_m, orig_n) - await self._send_command_plc("ST 1908") # pick plate from origin position + await self._send_command_plc("ST 1908") # pick plate from origin position await self._wait_ready() if orig_m != dest_m: - await self._send_command_plc(f"WR DM0 {dest_m}") # destination cassette # if different - await self._send_command_plc(f"WR DM23 {dest_step_size}") # motor step size - await self._send_command_plc(f"WR DM25 {dest_pos_num}") # number of positions in cassette - await self._send_command_plc(f"WR DM5 {dest_n}") # destination plate position # - await self._send_command_plc("ST 1909") # place plate in destination position + await self._send_command_plc(f"WR DM0 {dest_m}") # destination cassette # if different + await self._send_command_plc(f"WR DM23 {dest_step_size}") # motor step size + await self._send_command_plc(f"WR DM25 {dest_pos_num}") # number of positions in cassette + await self._send_command_plc(f"WR DM5 {dest_n}") # destination plate position # + await self._send_command_plc("ST 1909") # place plate in destination position await self._wait_ready() - await self._send_command_plc("ST 1903") # terminate access + await self._send_command_plc("ST 1903") # terminate access if read_barcode: return barcode @@ -268,13 +281,17 @@ async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: if self.barcode_installed: await self._send_command_plc("ST 1910") # move shovel to barcode reading position await self._wait_ready() - barcode = await self._send_command_bcr("LON") # read barcode + barcode = await self._send_command_bcr("LON") # read barcode if barcode is None: raise RuntimeError("Failed to read barcode from plate") elif barcode == "ERROR": - logger.info(f"No barcode found when reading plate at cassette {cassette}, position {plt_position}") + logger.info( + f"No barcode found when reading plate at cassette {cassette}, position {plt_position}" + ) else: - logger.info(f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode}") + logger.info( + f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode}" + ) reset = await self._send_command_plc("RS 1910") # move shovel back to normal position if reset != "OK": raise RuntimeError("Failed to reset shovel position after barcode reading") @@ -303,7 +320,6 @@ async def _send_command_plc(self, command: str) -> str: raise RuntimeError(f"Unknown error {resp} when sending command {command}") return resp - async def _send_command_bcr(self, command: str) -> str: """ Send an ASCII command to the barcode reader over serial and return the response. @@ -353,9 +369,9 @@ async def _wait_ready(self, timeout: int = 60): raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") async def set_temperature(self, temperature: float): - """ Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt - where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370) """ - if self.model.value.split('_')[-1] == "NC": + """Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt + where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370)""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") temp_value = int(temperature * 10) @@ -364,8 +380,8 @@ async def set_temperature(self, temperature: float): await self._wait_ready() async def get_temperature(self) -> float: - """ Get the temperature of the incubator in degrees Celsius. Using command RD DM982 """ - if self.model.value.split('_')[-1] == "NC": + """Get the temperature of the incubator in degrees Celsius. Using command RD DM982""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command_plc("RD DM982") @@ -379,7 +395,7 @@ async def get_temperature(self) -> float: # UNTESTED # Unsure if 1 means ON and 0 means OFF, needs to be confirmed. async def shaker_status(self) -> int: - """ Determines whether the shaker is ON (1) or OFF (0)""" + """Determines whether the shaker is ON (1) or OFF (0)""" value = await self._send_command_plc() await self._wait_ready() return value @@ -388,7 +404,7 @@ async def shaker_status(self) -> int: # Unsure if a liconic will return 00250 for 25 or 00025. Assuming former. # Should be in Hz async def get_shaker_speed(self) -> float: - """ Gets the current shaker speed default = 25""" + """Gets the current shaker speed default = 25""" speed_val = await self._send_command_plc("RD DM39") speed = speed_val / 10.0 await self._wait_ready() @@ -397,8 +413,8 @@ async def get_shaker_speed(self) -> float: # UNTESTED # Unsure if setting WR DM39 00250 will set it at 25 Hz or if WR DM39 00025 will. Assuming former async def start_shaking(self, frequency): - """ Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Using command - ST 1913. This functionality is not currently able to be tested. """ + """Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Using command + ST 1913. This functionality is not currently able to be tested.""" if frequency < 1.0 or frequency > 50.0: raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") else: @@ -410,13 +426,13 @@ async def start_shaking(self, frequency): # UNTESTED async def stop_shaking(self): - """ Stop shaking. Using command RS 1913 """ + """Stop shaking. Using command RS 1913""" await self._send_command_plc("RS 1913") await self._wait_ready() async def get_set_temperature(self) -> float: - """ Get the set value temperature of the incubator in degrees Celsius.""" - if self.model.value.split('_')[-1] == "NC": + """Get the set value temperature of the incubator in degrees Celsius.""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command_plc("RD DM890") @@ -428,8 +444,8 @@ async def get_set_temperature(self) -> float: raise RuntimeError(f"Invalid set temperature value received from incubator: {resp!r}") async def set_humidity(self, humidity: float): - """ Set the humidity of the incubator in percentage (%).""" - if self.model.value.split('_')[-1] == "NC": + """Set the humidity of the incubator in percentage (%).""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") humidity_val = int(humidity * 10) @@ -437,8 +453,8 @@ async def set_humidity(self, humidity: float): await self._wait_ready() async def get_humidity(self) -> float: - """ Get the actual humidity of the incubator in percentage (%).""" - if self.model.value.split('_')[-1] == "NC": + """Get the actual humidity of the incubator in percentage (%).""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command_plc("RD DM983") @@ -450,8 +466,8 @@ async def get_humidity(self) -> float: raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") async def get_set_humidity(self) -> float: - """ Get the set value humidity of the incubator in percentage (%).""" - if self.model.value.split('_')[-1] == "NC": + """Get the set value humidity of the incubator in percentage (%).""" + if self.model.value.split("_")[-1] == "NC": raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command_plc("RD DM893") @@ -464,14 +480,14 @@ async def get_set_humidity(self) -> float: # UNTESTED async def set_co2_level(self, co2_level: float): - """ Set the CO2 level of the incubator in 1/100% vol. percentage (%) 500 = 5.0 % .""" + """Set the CO2 level of the incubator in 1/100% vol. percentage (%) 500 = 5.0 % .""" co2_val = int(co2_level * 100) await self._send_command_plc(f"WR DM894 {str(co2_val).zfill(5)}") await self._wait_ready() # UNTESTED async def get_co2_level(self) -> float: - """ Get the CO2 level of the incubator in percentage (%).""" + """Get the CO2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM984") try: co2_value = int(resp) @@ -482,7 +498,7 @@ async def get_co2_level(self) -> float: # UNTESTED async def get_set_co2_level(self) -> float: - """ Get the set value CO2 level of the incubator in percentage (%).""" + """Get the set value CO2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM894") try: co2_set_value = int(resp) @@ -493,13 +509,13 @@ async def get_set_co2_level(self) -> float: # UNTESTED async def set_n2_level(self, n2_level: float): - """ Set the N2 level of the incubator in percentage (%).""" + """Set the N2 level of the incubator in percentage (%).""" n2_val = int(n2_level * 100) await self._send_command_plc(f"WR DM895 {str(n2_val).zfill(5)}") # UNTESTED async def get_n2_level(self) -> float: - """ Get the N2 level of the incubator in percentage (%).""" + """Get the N2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM985") try: n2_value = int(resp) @@ -510,7 +526,7 @@ async def get_n2_level(self) -> float: # UNTESTED async def get_set_n2_level(self) -> float: - """ Get the set value N2 level of the incubator in percentage (%).""" + """Get the set value N2 level of the incubator in percentage (%).""" resp = await self._send_command_plc("RD DM895") try: n2_set_value = int(resp) @@ -523,7 +539,7 @@ async def get_set_n2_level(self) -> float: # Unsure what RD 1912 returns (is 1 home or swapped?) # Another avenue is to read the first byte of T16 or T17 but don't have ability to test async def turn_swap_station(self, home: bool): - """ Turn the swap station of the incubator. If home is True, turn to home position.""" + """Turn the swap station of the incubator. If home is True, turn to home position.""" resp = await self._send_command_plc("RD 1912") if home and resp == "1": await self._send_command_plc("RS 1912") @@ -533,8 +549,8 @@ async def turn_swap_station(self, home: bool): # UNTESTED # Activate plate sensor (ST 1911) used in HT units only because it is off by default async def check_shovel_sensor(self) -> bool: - """ First need to activate shovel transfer sensor deactivated by default, wait 0.1 seconds - and then Check if the shovel plate sensor is activated.""" + """First need to activate shovel transfer sensor deactivated by default, wait 0.1 seconds + and then Check if the shovel plate sensor is activated.""" await self._send_command_plc("ST 1911") asyncio.sleep(0.1) resp = await self._send_command_plc("RD 1812") @@ -547,7 +563,7 @@ async def check_shovel_sensor(self) -> bool: # UNTESTED async def check_transfer_sensor(self) -> bool: - """ Check if the transfer plate sensor is activated.""" + """Check if the transfer plate sensor is activated.""" resp = await self._send_command_plc("RD 1813") if resp == "1": return True @@ -558,7 +574,7 @@ async def check_transfer_sensor(self) -> bool: # UNTESTED async def check_second_transfer_sensor(self) -> bool: - """ Check if the second transfer plate sensor is activated.""" + """Check if the second transfer plate sensor is activated.""" resp = await self._send_command_plc("RD 1807") if resp == "1": return True @@ -568,17 +584,17 @@ async def check_second_transfer_sensor(self) -> bool: raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") async def scan_barcode(self, site: PlateHolder) -> str: - """ Scan a barcode using the internal barcode reader. Using command LON """ + """Scan a barcode using the internal barcode reader. Using command LON""" if not self.barcode_installed: raise RuntimeError("Barcode reader not installed in this incubator instance") m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command_plc(f"WR DM0 {m}") # carousel number - await self._send_command_plc(f"WR DM23 {step_size}") # pitch of plate in mm - await self._send_command_plc(f"WR DM25 {pos_num}") # plate - await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel + await self._send_command_plc(f"WR DM0 {m}") # carousel number + await self._send_command_plc(f"WR DM23 {step_size}") # pitch of plate in mm + await self._send_command_plc(f"WR DM25 {pos_num}") # plate + await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel await self._send_command_plc("ST 1910") # move shovel to barcode reading position barcode = await self._send_command_bcr("LON") diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py index c93caf540ef..0fc340d43b7 100644 --- a/pylabrobot/storage/liconic/racks.py +++ b/pylabrobot/storage/liconic/racks.py @@ -1,25 +1,28 @@ +from typing import Optional + from pylabrobot.resources import Coordinate from pylabrobot.resources.carrier import PlateCarrier, PlateHolder -from typing import Optional -def _liconic_rack(name: str, - pitch: int, - steps: int, - site_height: int, - num_sites: int, - model: str, - total_height: Optional[int] = 505, # 645 and 1210 for STX 500 and STX1000 only - bicarousel: Optional[bool] = False # for STX500 and STX1000 only - ): - start = 17.2 # rough height of first plate position - pitch=pitch, - steps = steps, - bicarousel = bicarousel, + +def _liconic_rack( + name: str, + pitch: int, + steps: int, + site_height: int, + num_sites: int, + model: str, + total_height: Optional[int] = 505, # 645 and 1210 for STX 500 and STX1000 only + bicarousel: Optional[bool] = False, # for STX500 and STX1000 only +): + start = 17.2 # rough height of first plate position + pitch = (pitch,) + steps = (steps,) + bicarousel = (bicarousel,) return PlateCarrier( name=name, - size_x=109, # based off cytomat rack dimensions roughly the same + size_x=109, # based off cytomat rack dimensions roughly the same size_y=142, - size_z= total_height, + size_z=total_height, sites={ i: PlateHolder( size_x=85.48, @@ -30,7 +33,7 @@ def _liconic_rack(name: str, pedestal_size_z=0, ).at( Coordinate( - x=11.76, #estimate + x=11.76, # estimate y=0, z=start + site_height * i, ) @@ -40,116 +43,393 @@ def _liconic_rack(name: str, model=model, ) + """ The motor step size used to set DM23 in the Liconic is calculated using the known step size of for 23mm pitch which is 788 and for 50 mm which is 1713. Therefore for the other pitch sizes: step size = pitch / (50 / 1713) and then rounded to nearest whole number""" + def liconic_rack_5mm_42(name: str): - return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=42, model="liconic_rack_5mm_42") + return _liconic_rack( + name=name, pitch=11, steps=377, site_height=5, num_sites=42, model="liconic_rack_5mm_42" + ) + def liconic_rack_5mm_55(name: str): - return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=55, model="liconic_rack_5mm_55", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=11, + steps=377, + site_height=5, + num_sites=55, + model="liconic_rack_5mm_55", + total_height=645, + bicarousel=True, + ) + def liconic_rack_5mm_111(name: str): - return _liconic_rack(name=name, pitch=11, steps=377, site_height=5, num_sites=111, model="liconic_rack_5mm_111", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=11, + steps=377, + site_height=5, + num_sites=111, + model="liconic_rack_5mm_111", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_11mm_28(name: str): - return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=28, model="liconic_rack_11mm_28") + return _liconic_rack( + name=name, pitch=17, steps=582, site_height=11, num_sites=28, model="liconic_rack_11mm_28" + ) + def liconic_rack_11mm_37(name: str): - return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=37, model="liconic_rack_11mm_37", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=17, + steps=582, + site_height=11, + num_sites=37, + model="liconic_rack_11mm_37", + total_height=645, + bicarousel=True, + ) + def liconic_rack_11mm_72(name: str): - return _liconic_rack(name=name, pitch=17, steps=582, site_height=11, num_sites=72, model="liconic_rack_11mm_72", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=17, + steps=582, + site_height=11, + num_sites=72, + model="liconic_rack_11mm_72", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_12mm_27(name: str): - return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=27, model="liconic_rack_12mm_27") + return _liconic_rack( + name=name, pitch=18, steps=617, site_height=12, num_sites=27, model="liconic_rack_12mm_27" + ) + def liconic_rack_12mm_35(name: str): - return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=35, model="liconic_rack_12mm_35", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=18, + steps=617, + site_height=12, + num_sites=35, + model="liconic_rack_12mm_35", + total_height=645, + bicarousel=True, + ) + def liconic_rack_12mm_68(name: str): - return _liconic_rack(name=name, pitch=18, steps=617, site_height=12, num_sites=68, model="liconic_rack_12mm_68", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=18, + steps=617, + site_height=12, + num_sites=68, + model="liconic_rack_12mm_68", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_17mm_22(name: str): - return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=22, model="liconic_rack_17mm_22") + return _liconic_rack( + name=name, pitch=23, steps=788, site_height=17, num_sites=22, model="liconic_rack_17mm_22" + ) + def liconic_rack_17mm_28(name: str): - return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=28, model="liconic_rack_17mm_28", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=23, + steps=788, + site_height=17, + num_sites=28, + model="liconic_rack_17mm_28", + total_height=645, + bicarousel=True, + ) + def liconic_rack_17mm_53(name: str): - return _liconic_rack(name=name, pitch=23, steps=788, site_height=17, num_sites=53, model="liconic_rack_17mm_53", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=23, + steps=788, + site_height=17, + num_sites=53, + model="liconic_rack_17mm_53", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_22mm_17(name: str): - return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=17, model="liconic_rack_22mm_17") + return _liconic_rack( + name=name, pitch=28, steps=959, site_height=22, num_sites=17, model="liconic_rack_22mm_17" + ) + def liconic_rack_22mm_23(name: str): - return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=23, model="liconic_rack_22mm_23", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=28, + steps=959, + site_height=22, + num_sites=23, + model="liconic_rack_22mm_23", + total_height=645, + bicarousel=True, + ) + def liconic_rack_22mm_43(name: str): - return _liconic_rack(name=name, pitch=28, steps=959, site_height=22, num_sites=43, model="liconic_rack_22mm_43", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=28, + steps=959, + site_height=22, + num_sites=43, + model="liconic_rack_22mm_43", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_23mm_17(name: str): - return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=17, model="liconic_rack_23mm_17") + return _liconic_rack( + name=name, pitch=29, steps=994, site_height=23, num_sites=17, model="liconic_rack_23mm_17" + ) + def liconic_rack_23mm_22(name: str): - return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=22, model="liconic_rack_23mm_22", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=29, + steps=994, + site_height=23, + num_sites=22, + model="liconic_rack_23mm_22", + total_height=645, + bicarousel=True, + ) + def liconic_rack_23mm_42(name: str): - return _liconic_rack(name=name, pitch=29, steps=994, site_height=23, num_sites=42, model="liconic_rack_23mm_42", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=29, + steps=994, + site_height=23, + num_sites=42, + model="liconic_rack_23mm_42", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_24mm_17(name: str): - return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=17, model="liconic_rack_24mm_17") + return _liconic_rack( + name=name, pitch=30, steps=1028, site_height=24, num_sites=17, model="liconic_rack_24mm_17" + ) + def liconic_rack_24mm_21(name: str): - return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=21, model="liconic_rack_24mm_21", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=30, + steps=1028, + site_height=24, + num_sites=21, + model="liconic_rack_24mm_21", + total_height=645, + bicarousel=True, + ) + def liconic_rack_24mm_41(name: str): - return _liconic_rack(name=name, pitch=30, steps=1028, site_height=24, num_sites=41, model="liconic_rack_24mm_41", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=30, + steps=1028, + site_height=24, + num_sites=41, + model="liconic_rack_24mm_41", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_27mm_15(name: str): - return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=15, model="liconic_rack_27mm_15") + return _liconic_rack( + name=name, pitch=33, steps=1131, site_height=27, num_sites=15, model="liconic_rack_27mm_15" + ) + def liconic_rack_27mm_19(name: str): - return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=19, model="liconic_rack_27mm_19", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=33, + steps=1131, + site_height=27, + num_sites=19, + model="liconic_rack_27mm_19", + total_height=645, + bicarousel=True, + ) + def liconic_rack_27mm_37(name: str): - return _liconic_rack(name=name, pitch=33, steps=1131, site_height=27, num_sites=37, model="liconic_rack_27mm_37", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=33, + steps=1131, + site_height=27, + num_sites=37, + model="liconic_rack_27mm_37", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_44mm_10(name: str): - return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=10, model="liconic_rack_44mm_10") + return _liconic_rack( + name=name, pitch=50, steps=1713, site_height=44, num_sites=10, model="liconic_rack_44mm_10" + ) + def liconic_rack_44mm_13(name: str): - return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=13, model="liconic_rack_44mm_13", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=50, + steps=1713, + site_height=44, + num_sites=13, + model="liconic_rack_44mm_13", + total_height=645, + bicarousel=True, + ) + def liconic_rack_44mm_25(name: str): - return _liconic_rack(name=name, pitch=50, steps=1713, site_height=44, num_sites=25, model="liconic_rack_44mm_25", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=50, + steps=1713, + site_height=44, + num_sites=25, + model="liconic_rack_44mm_25", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_53mm_8(name: str): - return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=8, model="liconic_rack_53mm_8") + return _liconic_rack( + name=name, pitch=59, steps=2021, site_height=53, num_sites=8, model="liconic_rack_53mm_8" + ) + def liconic_rack_53mm_10(name: str): - return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=10, model="liconic_rack_53mm_10", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=59, + steps=2021, + site_height=53, + num_sites=10, + model="liconic_rack_53mm_10", + total_height=645, + bicarousel=True, + ) + def liconic_rack_53mm_21(name: str): - return _liconic_rack(name=name, pitch=59, steps=2021, site_height=53, num_sites=21, model="liconic_rack_53mm_21", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=59, + steps=2021, + site_height=53, + num_sites=21, + model="liconic_rack_53mm_21", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_66mm_7(name: str): - return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=7, model="liconic_rack_66mm_7") + return _liconic_rack( + name=name, pitch=72, steps=2467, site_height=66, num_sites=7, model="liconic_rack_66mm_7" + ) + def liconic_rack_66mm_8(name: str): - return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=8, model="liconic_rack_66mm_8", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=72, + steps=2467, + site_height=66, + num_sites=8, + model="liconic_rack_66mm_8", + total_height=645, + bicarousel=True, + ) + def liconic_rack_66mm_17(name: str): - return _liconic_rack(name=name, pitch=72, steps=2467, site_height=66, num_sites=17, model="liconic_rack_66mm_17", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=72, + steps=2467, + site_height=66, + num_sites=17, + model="liconic_rack_66mm_17", + total_height=1210, + bicarousel=True, + ) + def liconic_rack_104mm_4(name: str): - return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=4, model="liconic_rack_104mm_4") + return _liconic_rack( + name=name, pitch=110, steps=3563, site_height=104, num_sites=4, model="liconic_rack_104mm_4" + ) + def liconic_rack_104mm_5(name: str): - return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=5, model="liconic_rack_104mm_5", total_height=645, bicarousel=True) + return _liconic_rack( + name=name, + pitch=110, + steps=3563, + site_height=104, + num_sites=5, + model="liconic_rack_104mm_5", + total_height=645, + bicarousel=True, + ) + def liconic_rack_104mm_11(name: str): - return _liconic_rack(name=name, pitch=110, steps=3563, site_height=104, num_sites=11, model="liconic_rack_104mm_11", total_height=1210, bicarousel=True) + return _liconic_rack( + name=name, + pitch=110, + steps=3563, + site_height=104, + num_sites=11, + model="liconic_rack_104mm_11", + total_height=1210, + bicarousel=True, + ) From 420d067c1b3b613925453c3abcec5b4f04a2abed Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:34:07 -0800 Subject: [PATCH 31/35] Add BarcodeScanner frontend class - Add BarcodeScanner frontend that wraps BarcodeScannerBackend - Export BarcodeScanner and BarcodeScannerError from __init__.py - Fix type hints in KeyenceBarcodeScannerBackend (callable -> Callable) Co-Authored-By: Claude Opus 4.5 --- pylabrobot/barcode_scanners/__init__.py | 3 ++- pylabrobot/barcode_scanners/barcode_scanner.py | 14 ++++++++++++++ .../keyence/barcode_scanner_backend.py | 6 +++--- 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 pylabrobot/barcode_scanners/barcode_scanner.py diff --git a/pylabrobot/barcode_scanners/__init__.py b/pylabrobot/barcode_scanners/__init__.py index d562fe1c6bb..befd981f9a9 100644 --- a/pylabrobot/barcode_scanners/__init__.py +++ b/pylabrobot/barcode_scanners/__init__.py @@ -1,2 +1,3 @@ -from .backend import BarcodeScannerBackend +from .backend import BarcodeScannerBackend, BarcodeScannerError +from .barcode_scanner import BarcodeScanner from .keyence import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/barcode_scanners/barcode_scanner.py b/pylabrobot/barcode_scanners/barcode_scanner.py new file mode 100644 index 00000000000..dbd168c076a --- /dev/null +++ b/pylabrobot/barcode_scanners/barcode_scanner.py @@ -0,0 +1,14 @@ +from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.machines.machine import Machine + + +class BarcodeScanner(Machine): + """Frontend for barcode scanners.""" + + def __init__(self, backend: BarcodeScannerBackend): + super().__init__(backend=backend) + self.backend: BarcodeScannerBackend = backend + + async def scan(self) -> str: + """Scan a barcode and return its value.""" + return await self.backend.scan_barcode() diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index 32a56c63932..ddc3e5085a3 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -1,6 +1,6 @@ import asyncio import time -from typing import Optional +from typing import Awaitable, Callable, Optional import serial @@ -70,9 +70,9 @@ async def send_command(self, command: str) -> str: async def send_command_and_stream( self, command: str, - on_response: callable, + on_response: Callable[[str], Awaitable[None]], timeout: float = 5.0, - stop_condition: Optional[callable] = None, + stop_condition: Optional[Callable[[str], bool]] = None, ): """Send a command and call on_response for each barcode response.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) From 1579d789fb62e50cf0b3e36c982f83b8834beaf8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:36:36 -0800 Subject: [PATCH 32/35] Change barcode scanner return type from str to Barcode Co-Authored-By: Claude Opus 4.5 --- pylabrobot/barcode_scanners/backend.py | 5 +++-- pylabrobot/barcode_scanners/barcode_scanner.py | 3 ++- .../barcode_scanners/keyence/barcode_scanner_backend.py | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/barcode_scanners/backend.py index 43e83c27485..ce7bbe2b7a1 100644 --- a/pylabrobot/barcode_scanners/backend.py +++ b/pylabrobot/barcode_scanners/backend.py @@ -1,6 +1,7 @@ from abc import ABCMeta, abstractmethod from pylabrobot.machines.backend import MachineBackend +from pylabrobot.resources.barcode import Barcode class BarcodeScannerError(Exception): @@ -12,6 +13,6 @@ def __init__(self): super().__init__() @abstractmethod - async def scan_barcode(self) -> str: - """Scan a barcode and return its value as a string.""" + async def scan_barcode(self) -> Barcode: + """Scan a barcode and return its value.""" pass diff --git a/pylabrobot/barcode_scanners/barcode_scanner.py b/pylabrobot/barcode_scanners/barcode_scanner.py index dbd168c076a..821e5789ae2 100644 --- a/pylabrobot/barcode_scanners/barcode_scanner.py +++ b/pylabrobot/barcode_scanners/barcode_scanner.py @@ -1,5 +1,6 @@ from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend from pylabrobot.machines.machine import Machine +from pylabrobot.resources.barcode import Barcode class BarcodeScanner(Machine): @@ -9,6 +10,6 @@ def __init__(self, backend: BarcodeScannerBackend): super().__init__(backend=backend) self.backend: BarcodeScannerBackend = backend - async def scan(self) -> str: + async def scan(self) -> Barcode: """Scan a barcode and return its value.""" return await self.backend.scan_barcode() diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index ddc3e5085a3..e8d0eeba543 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -9,6 +9,7 @@ BarcodeScannerError, ) from pylabrobot.io.serial import Serial +from pylabrobot.resources.barcode import Barcode class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): @@ -101,5 +102,6 @@ async def send_command_and_stream( async def stop(self): await self.io.stop() - async def scan_barcode(self) -> str: - return await self.send_command("LON") + async def scan_barcode(self) -> Barcode: + data = await self.send_command("LON") + return Barcode(data=data, symbology="unknown", position_on_resource="front") From b50f06f659535e7146fd770d73c2bb22bf160a69 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 21:41:27 -0800 Subject: [PATCH 33/35] Fix type annotation in fetch_plate_to_loading_tray Parameter was annotated as str but used as Plate object. Co-Authored-By: Claude Opus 4.5 --- pylabrobot/storage/liconic/liconic_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 22ac3ddffc8..05bd6e10661 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -187,7 +187,7 @@ async def close_door(self): await self._wait_ready() async def fetch_plate_to_loading_tray( - self, plate: str, read_barcode: Optional[bool] = False + self, plate: Plate, read_barcode: Optional[bool] = False ) -> Optional[str]: """Fetch a plate from the incubator to the loading tray.""" site = plate.parent From 19b454bc0074693ce6c2a0a302f8ad8f4c524917 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 22:12:45 -0800 Subject: [PATCH 34/35] Refactor barcode handling to use dependency injection - LiconicBackend now accepts optional BarcodeScanner instance instead of creating KeyenceBarcodeScannerBackend internally - Backend methods set plate.barcode instead of returning barcode strings - Incubator frontend uses **backend_kwargs pattern to pass read_barcode to backend methods without changing signature - Add Keyence-specific error handling (NG/ERR99) to KeyenceBarcodeScannerBackend - Update abstract method signatures in IncubatorBackend Co-Authored-By: Claude Opus 4.5 --- .../keyence/barcode_scanner_backend.py | 4 + pylabrobot/storage/backend.py | 15 ++- pylabrobot/storage/incubator.py | 48 ++----- pylabrobot/storage/liconic/liconic_backend.py | 122 +++++------------- 4 files changed, 58 insertions(+), 131 deletions(-) diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py index e8d0eeba543..219b37a23d2 100644 --- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py +++ b/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py @@ -104,4 +104,8 @@ async def stop(self): async def scan_barcode(self) -> Barcode: data = await self.send_command("LON") + if data.startswith("NG"): + raise BarcodeScannerError("Barcode reader is off: cannot read barcode") + if data.startswith("ERR99"): + raise BarcodeScannerError(f"Error response from barcode reader: {data}") return Barcode(data=data, symbology="unknown", position_on_resource="front") diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py index b74cb12e191..2a0bacbac2b 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/storage/backend.py @@ -3,6 +3,7 @@ from pylabrobot.machines.backend import MachineBackend from pylabrobot.resources import Plate, PlateCarrier, PlateHolder +from pylabrobot.resources.barcode import Barcode class IncubatorBackend(MachineBackend, metaclass=ABCMeta): @@ -27,11 +28,11 @@ async def close_door(self): pass @abstractmethod - async def fetch_plate_to_loading_tray(self, plate: Plate): + async def fetch_plate_to_loading_tray(self, plate: Plate, **kwargs): pass @abstractmethod - async def take_in_plate(self, plate: Plate, site: PlateHolder): + async def take_in_plate(self, plate: Plate, site: PlateHolder, **kwargs): pass @abstractmethod @@ -124,11 +125,13 @@ async def check_second_transfer_sensor(self) -> bool: pass @abstractmethod - async def scan_barcode(self, m: int, n: int, pitch: int, plt_count: int): - """Scan barcode at given position with specified pitch and timeout.""" + async def scan_barcode(self, site: PlateHolder) -> Barcode: + """Scan barcode at given position.""" pass @abstractmethod - async def move_position_to_position(self, plate_name: str, dest_site: PlateHolder): - """Move plate by name to another position in the storage unit""" + async def move_position_to_position( + self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False + ): + """Move plate to another position in the storage unit""" pass diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py index b19a48a264c..a54764893b6 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/storage/incubator.py @@ -73,22 +73,13 @@ def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: return site raise ResourceNotFoundError(f"Plate {plate_name} not found in incubator '{self.name}'") - async def fetch_plate_to_loading_tray( - self, plate_name: str, read_barcode: Optional[bool] = False - ) -> Plate: + async def fetch_plate_to_loading_tray(self, plate_name: str, **backend_kwargs) -> Plate: """Fetch a plate from the incubator and put it on the loading tray.""" site = self.get_site_by_plate_name(plate_name) plate = site.resource assert plate is not None - - if read_barcode: - barcode = await self.backend.fetch_plate_to_loading_tray(plate, read_barcode) - print(barcode) - # undecided with what we want to do with barcode string (no Plate variable for it) - else: - await self.backend.fetch_plate_to_loading_tray(plate) - + await self.backend.fetch_plate_to_loading_tray(plate, **backend_kwargs) plate.unassign() self.loading_tray.assign_child_resource(plate) return plate @@ -122,9 +113,7 @@ def find_random_site(self, plate: Plate) -> PlateHolder: return random.choice(self._find_available_sites_sorted(plate)) async def take_in_plate( - self, - site: Union[PlateHolder, Literal["random", "smallest"]], - read_barcode: Optional[bool] = False, + self, site: Union[PlateHolder, Literal["random", "smallest"]], **backend_kwargs ): """Take a plate from the loading tray and put it in the incubator.""" @@ -141,14 +130,7 @@ async def take_in_plate( raise ValueError(f"Site {site.name} is not available for plate {plate.name}") else: raise ValueError(f"Invalid site: {site}") - - if read_barcode: - barcode = await self.backend.take_in_plate(plate, site, read_barcode) - print(barcode) - # undecided with what we want to do with barcode string (no Plate variable for it) - else: - await self.backend.take_in_plate(plate, site) - + await self.backend.take_in_plate(plate, site, **backend_kwargs) plate.unassign() site.assign_child_resource(plate) @@ -171,9 +153,6 @@ async def start_shaking(self, frequency: float = 1.0): async def stop_shaking(self): await self.backend.stop_shaking() - async def scan_barcode(self, site: PlateHolder): - await self.backend.scan_barcode(self, site) - def summary(self) -> str: def create_pretty_table(header, *columns) -> str: col_widths = [ @@ -231,7 +210,8 @@ def deserialize(cls, data: dict, allow_marshal: bool = False): model=data["model"], ) - """ Methods added for Liconic incubator options.""" + async def scan_barcode(self, site: PlateHolder): + return await self.backend.scan_barcode(site) async def get_set_temperature(self) -> float: """Get the set value temperature of the incubator in degrees Celsius.""" @@ -290,21 +270,13 @@ async def check_second_transfer_sensor(self) -> bool: return await self.backend.check_second_transfer_sensor() async def move_position_to_position( - self, plate_name: str, dest_site: PlateHolder, read_barcode: Optional[bool] = False + self, plate_name: str, dest_site: PlateHolder, **backend_kwargs ) -> Plate: - """Move a plate to another internal position in the storage unit""" + """Move a plate to another internal position in the storage unit.""" site = self.get_site_by_plate_name(plate_name) plate = site.resource assert plate is not None - - if read_barcode: - barcode = await self.backend.move_position_to_position(plate, dest_site, read_barcode) - print(barcode) - # undecided with what we want to do with barcode string (no Plate variable for it) - else: - await self.backend.move_position_to_position(plate, dest_site) - + await self.backend.move_position_to_position(plate, dest_site, **backend_kwargs) plate.unassign() - site.assign_child_resource(plate) - + dest_site.assign_child_resource(plate) return plate diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 05bd6e10661..13cf4e9274a 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -7,9 +7,10 @@ import serial -from pylabrobot.barcode_scanners.keyence import KeyenceBarcodeScannerBackend +from pylabrobot.barcode_scanners import BarcodeScanner from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.carrier import PlateCarrier from pylabrobot.storage.backend import IncubatorBackend from pylabrobot.storage.liconic.constants import ControllerError, HandlingError, LiconicType @@ -35,10 +36,9 @@ class LiconicBackend(IncubatorBackend): - """ - Backend for Liconic incubators. - Written to connect with internal barcode reader and gas control. - Barcode reader tested is the Keyence BL-1300 + """Backend for Liconic incubators. + + Optionally accepts a BarcodeScanner instance for internal barcode reading. """ default_baud = 9600 @@ -51,13 +51,11 @@ def __init__( self, model: Union[LiconicType, str], port: str, - barcode_installed: Optional[bool] = None, - barcode_port: Optional[str] = None, + barcode_scanner: Optional[BarcodeScanner] = None, ): super().__init__() - self.barcode_installed: Optional[bool] = barcode_installed - self.barcode_port: Optional[str] = barcode_port + self.barcode_scanner = barcode_scanner if isinstance(model, str): try: @@ -79,11 +77,6 @@ def __init__( rtscts=True, ) - if barcode_installed: - if not barcode_port: - raise ValueError("barcode_port must also be provided if barcode is installed") - self.io_bcr = KeyenceBarcodeScannerBackend(serial_port=barcode_port) - self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None @@ -133,13 +126,6 @@ async def setup(self): await self.io_plc.stop() raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") - if self.io_bcr is not None: - try: - await self.io_bcr.setup() - except Exception as e: - await self.io_bcr.stop() - raise RuntimeError(f"Could not setup barcode reader on {self.barcode_port}: {e}") - def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: rack = site.parent assert isinstance(rack, PlateCarrier), "Site not in rack" @@ -166,8 +152,6 @@ def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: async def stop(self): await self.io_plc.stop() - if self.io_bcr is not None: - await self.io_bcr.stop() async def set_racks(self, racks: List[PlateCarrier]): await super().set_racks(racks) @@ -186,9 +170,7 @@ async def close_door(self): await self._send_command_plc("ST 1902") await self._wait_ready() - async def fetch_plate_to_loading_tray( - self, plate: Plate, read_barcode: Optional[bool] = False - ) -> Optional[str]: + async def fetch_plate_to_loading_tray(self, plate: Plate, read_barcode: bool = False): """Fetch a plate from the incubator to the loading tray.""" site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" @@ -202,18 +184,13 @@ async def fetch_plate_to_loading_tray( await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel if read_barcode: - barcode = await self.read_barcode_inline(m, n) + plate.barcode = await self.read_barcode_inline(m, n) await self._send_command_plc("ST 1905") # plate to transfer station await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - if read_barcode: - return barcode - - async def take_in_plate( - self, plate: Plate, site: PlateHolder, read_barcode: Optional[bool] = False - ) -> Optional[str]: + async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: bool = False): """Take in a plate from the loading tray to the incubator.""" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -226,17 +203,13 @@ async def take_in_plate( await self._wait_ready() if read_barcode: - barcode = await self.read_barcode_inline(m, n) - print(barcode) + plate.barcode = await self.read_barcode_inline(m, n) await self._send_command_plc("ST 1903") # terminate access - if read_barcode: - return barcode - async def move_position_to_position( - self, plate: Plate, dest_site: PlateHolder, read_barcode: Optional[bool] = False - ) -> Optional[str]: + self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False + ): """Move plate from one internal position to another""" orig_site = plate.parent assert isinstance(orig_site, PlateHolder) @@ -258,7 +231,7 @@ async def move_position_to_position( await self._send_command_plc(f"WR DM5 {orig_n}") # origin plate position # if read_barcode: - barcode = await self.read_barcode_inline(orig_m, orig_n) + plate.barcode = await self.read_barcode_inline(orig_m, orig_n) await self._send_command_plc("ST 1908") # pick plate from origin position @@ -274,32 +247,21 @@ async def move_position_to_position( await self._wait_ready() await self._send_command_plc("ST 1903") # terminate access - if read_barcode: - return barcode - - async def read_barcode_inline(self, cassette: int, plt_position: int) -> str: - if self.barcode_installed: - await self._send_command_plc("ST 1910") # move shovel to barcode reading position - await self._wait_ready() - barcode = await self._send_command_bcr("LON") # read barcode - if barcode is None: - raise RuntimeError("Failed to read barcode from plate") - elif barcode == "ERROR": - logger.info( - f"No barcode found when reading plate at cassette {cassette}, position {plt_position}" - ) - else: - logger.info( - f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode}" - ) - reset = await self._send_command_plc("RS 1910") # move shovel back to normal position - if reset != "OK": - raise RuntimeError("Failed to reset shovel position after barcode reading") - await self._wait_ready() - return barcode - else: - logger.info(" Barcode reading requested but instance not configured with barcode reader.") - return "No barcode" + async def read_barcode_inline(self, cassette: int, plt_position: int) -> Barcode: + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + + await self._send_command_plc("ST 1910") # move shovel to barcode reading position + await self._wait_ready() + barcode = await self.barcode_scanner.scan() + logger.info( + f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode.data}" + ) + reset = await self._send_command_plc("RS 1910") # move shovel back to normal position + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") + await self._wait_ready() + return barcode async def _send_command_plc(self, command: str) -> str: """ @@ -320,20 +282,6 @@ async def _send_command_plc(self, command: str) -> str: raise RuntimeError(f"Unknown error {resp} when sending command {command}") return resp - async def _send_command_bcr(self, command: str) -> str: - """ - Send an ASCII command to the barcode reader over serial and return the response. - """ - resp = await self.io_bcr.send_command(command) - if not resp: - raise RuntimeError(f"No response from Barcode Reader for command {command!r}") - resp = resp.strip() - if resp.startswith("NG"): - raise RuntimeError("Barcode reader is off: cannot read barcode") - elif resp.startswith("ERR99"): - raise RuntimeError(f"Error response from Barcode Reader for command {command!r}: {resp!r}") - return resp - async def _wait_plate_ready(self, timeout: int = 60): """ Poll the plate-ready flag (RD 1914) until it is set, or timeout is reached. @@ -583,10 +531,10 @@ async def check_second_transfer_sensor(self) -> bool: else: raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") - async def scan_barcode(self, site: PlateHolder) -> str: - """Scan a barcode using the internal barcode reader. Using command LON""" - if not self.barcode_installed: - raise RuntimeError("Barcode reader not installed in this incubator instance") + async def scan_barcode(self, site: PlateHolder) -> Barcode: + """Scan a barcode using the internal barcode reader.""" + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) @@ -597,8 +545,8 @@ async def scan_barcode(self, site: PlateHolder) -> str: await self._send_command_plc(f"WR DM5 {n}") # plate position in carousel await self._send_command_plc("ST 1910") # move shovel to barcode reading position - barcode = await self._send_command_bcr("LON") - print(f"Scanned barcode: {barcode}") + barcode = await self.barcode_scanner.scan() + logger.info(f"Scanned barcode: {barcode.data}") return barcode def serialize(self) -> dict: From b316aa2334651003c54acd54ea3cd597b14e4d3e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 22:14:16 -0800 Subject: [PATCH 35/35] Fix bugs in LiconicBackend - shaker_status: raise NotImplementedError (missing PLC command) - get_shaker_speed: add int() conversion before division - check_shovel_sensor: add missing await on asyncio.sleep Co-Authored-By: Claude Opus 4.5 --- pylabrobot/storage/liconic/liconic_backend.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py index 13cf4e9274a..16269cb9458 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/storage/liconic/liconic_backend.py @@ -344,9 +344,8 @@ async def get_temperature(self) -> float: # Unsure if 1 means ON and 0 means OFF, needs to be confirmed. async def shaker_status(self) -> int: """Determines whether the shaker is ON (1) or OFF (0)""" - value = await self._send_command_plc() - await self._wait_ready() - return value + # TODO: Missing PLC command - need to determine correct command from Liconic documentation + raise NotImplementedError("shaker_status command not yet implemented") # UNTESTED # Unsure if a liconic will return 00250 for 25 or 00025. Assuming former. @@ -354,7 +353,7 @@ async def shaker_status(self) -> int: async def get_shaker_speed(self) -> float: """Gets the current shaker speed default = 25""" speed_val = await self._send_command_plc("RD DM39") - speed = speed_val / 10.0 + speed = int(speed_val) / 10.0 await self._wait_ready() return speed @@ -500,7 +499,7 @@ async def check_shovel_sensor(self) -> bool: """First need to activate shovel transfer sensor deactivated by default, wait 0.1 seconds and then Check if the shovel plate sensor is activated.""" await self._send_command_plc("ST 1911") - asyncio.sleep(0.1) + await asyncio.sleep(0.1) resp = await self._send_command_plc("RD 1812") if resp == "1": return True