From 9a5060622871bcee8092270a199d8d82cee55de2 Mon Sep 17 00:00:00 2001
From: Sam Burns
Date: Tue, 13 Jan 2026 10:32:46 +0100
Subject: [PATCH 01/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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/56] 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
From 01a69c0d4ff63fa42e81ebbc9b79e58a0f54f3bb Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:10:19 -0800
Subject: [PATCH 36/56] logger in KeyenceBarcodeScannerBackend
---
pylabrobot/barcode_scanners/keyence/__init__.py | 2 +-
...ode_scanner_backend.py => keyence_backend.py} | 16 +++++++++-------
2 files changed, 10 insertions(+), 8 deletions(-)
rename pylabrobot/barcode_scanners/keyence/{barcode_scanner_backend.py => keyence_backend.py} (89%)
diff --git a/pylabrobot/barcode_scanners/keyence/__init__.py b/pylabrobot/barcode_scanners/keyence/__init__.py
index 7f99f5acbdd..db64521ca5c 100644
--- a/pylabrobot/barcode_scanners/keyence/__init__.py
+++ b/pylabrobot/barcode_scanners/keyence/__init__.py
@@ -1 +1 @@
-from .barcode_scanner_backend import KeyenceBarcodeScannerBackend
+from .keyence_backend import KeyenceBarcodeScannerBackend
diff --git a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
similarity index 89%
rename from pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py
rename to pylabrobot/barcode_scanners/keyence/keyence_backend.py
index 219b37a23d2..89b9761e25c 100644
--- a/pylabrobot/barcode_scanners/keyence/barcode_scanner_backend.py
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
import time
from typing import Awaitable, Callable, Optional
@@ -11,6 +12,8 @@
from pylabrobot.io.serial import Serial
from pylabrobot.resources.barcode import Barcode
+logger = logging.getLogger(__name__)
+
class KeyenceBarcodeScannerBackend(BarcodeScannerBackend):
default_baudrate = 9600
@@ -39,9 +42,9 @@ def __init__(
async def setup(self):
await self.io.setup()
- await self.initialize_scanner()
+ await self.initialize()
- async def initialize_scanner(self):
+ async def initialize(self):
"""Initialize the Keyence barcode scanner."""
response = await self.send_command("RMOTOR")
@@ -50,7 +53,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.")
+ logger.info("Barcode scanner motor is ON.")
break
elif response.strip() == "MOTOROFF":
raise BarcodeScannerError("Failed to initialize Keyence barcode scanner: Motor is off.")
@@ -84,19 +87,18 @@ async def send_command_and_stream(
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}")
+ logger.error(f"Error in on_response callback: {e}", exc_info=True)
if stop_condition and stop_condition(decoded):
break
except asyncio.TimeoutError:
- print("Barcode scanner timeout, continuing...")
+ logger.warning("Timeout while waiting for barcode scanner response.")
continue
except Exception as e:
- print(f"Error reading from barcode scanner: {e}")
+ logger.error(f"Error while reading from barcode scanner: {e}", exc_info=True)
continue
async def stop(self):
From 24226ea46bdad2c3d709c1a79eab7433177604a5 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:13:15 -0800
Subject: [PATCH 37/56] get_set_ -> get_target_
---
.../01_material-handling/storage/liconic.ipynb | 2 +-
pylabrobot/storage/backend.py | 8 ++++----
pylabrobot/storage/incubator.py | 16 ++++++++--------
pylabrobot/storage/liconic/liconic_backend.py | 8 ++++----
4 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb
index aa361977397..ed896377ec6 100644
--- a/docs/user_guide/01_material-handling/storage/liconic.ipynb
+++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb
@@ -219,7 +219,7 @@
"metadata": {},
"outputs": [],
"source": [
- "set_temperature = await incubator.get_set_temperature() # will return a float for the set temperature in degrees Celsius"
+ "set_temperature = await incubator.get_target_temperature() # will return a float for the set temperature in degrees Celsius"
]
},
{
diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py
index 2a0bacbac2b..436f32c588f 100644
--- a/pylabrobot/storage/backend.py
+++ b/pylabrobot/storage/backend.py
@@ -55,7 +55,7 @@ async def stop_shaking(self):
""" Methods added for Liconic incubator options."""
@abstractmethod
- async def get_set_temperature(self) -> float:
+ async def get_target_temperature(self) -> float:
"""Get the set value temperature of the incubator in degrees Celsius."""
pass
@@ -70,7 +70,7 @@ async def get_humidity(self) -> float:
pass
@abstractmethod
- async def get_set_humidity(self) -> float:
+ async def get_target_humidity(self) -> float:
"""Get the set value humidity of the incubator in % RH; e.g. 90.0% RH."""
pass
@@ -85,7 +85,7 @@ async def get_co2_level(self) -> float:
pass
@abstractmethod
- async def get_set_co2_level(self) -> float:
+ async def get_target_co2_level(self) -> float:
"""Get the set value CO2 level of the incubator in %; e.g. 5.0%."""
pass
@@ -100,7 +100,7 @@ async def get_n2_level(self) -> float:
pass
@abstractmethod
- async def get_set_n2_level(self) -> float:
+ async def get_target_n2_level(self) -> float:
"""Get the set value N2 level of the incubator in %; e.g. 90.0%."""
pass
diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py
index a54764893b6..bb691b678cf 100644
--- a/pylabrobot/storage/incubator.py
+++ b/pylabrobot/storage/incubator.py
@@ -213,9 +213,9 @@ def deserialize(cls, data: dict, allow_marshal: bool = False):
async def scan_barcode(self, site: PlateHolder):
return await self.backend.scan_barcode(site)
- async def get_set_temperature(self) -> float:
+ async def get_target_temperature(self) -> float:
"""Get the set value temperature of the incubator in degrees Celsius."""
- return await self.backend.get_set_temperature()
+ return await self.backend.get_target_temperature()
async def set_humidity(self, humidity: float):
"""Set the humidity of the incubator in percentage (%)."""
@@ -225,9 +225,9 @@ 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:
+ async def get_target_humidity(self) -> float:
"""Get the set value humidity of the incubator in percentage (%)."""
- return await self.backend.get_set_humidity()
+ return await self.backend.get_target_humidity()
async def set_co2_level(self, co2_level: float):
"""Set the CO2 level of the incubator in percentage (%)."""
@@ -237,9 +237,9 @@ 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:
+ async def get_target_co2_level(self) -> float:
"""Get the set value CO2 level of the incubator in percentage (%)."""
- return await self.backend.get_set_co2_level()
+ return await self.backend.get_target_co2_level()
async def set_n2_level(self, n2_level: float):
"""Set the N2 level of the incubator in percentage (%)."""
@@ -249,9 +249,9 @@ 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:
+ async def get_target_n2_level(self) -> float:
"""Get the set value N2 level of the incubator in percentage (%)."""
- return await self.backend.get_set_n2_level()
+ return await self.backend.get_target_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."""
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 16269cb9458..a749c261fa4 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -377,7 +377,7 @@ async def stop_shaking(self):
await self._send_command_plc("RS 1913")
await self._wait_ready()
- async def get_set_temperature(self) -> float:
+ async def get_target_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")
@@ -412,7 +412,7 @@ async def get_humidity(self) -> float:
except ValueError:
raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}")
- async def get_set_humidity(self) -> float:
+ async def get_target_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")
@@ -444,7 +444,7 @@ async def get_co2_level(self) -> float:
raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}")
# UNTESTED
- async def get_set_co2_level(self) -> float:
+ async def get_target_co2_level(self) -> float:
"""Get the set value CO2 level of the incubator in percentage (%)."""
resp = await self._send_command_plc("RD DM894")
try:
@@ -472,7 +472,7 @@ async def get_n2_level(self) -> float:
raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}")
# UNTESTED
- async def get_set_n2_level(self) -> float:
+ async def get_target_n2_level(self) -> float:
"""Get the set value N2 level of the incubator in percentage (%)."""
resp = await self._send_command_plc("RD DM895")
try:
From 99c4196da9ca497e51a1817ce75ccc704eef3d50 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:20:48 -0800
Subject: [PATCH 38/56] more hardware agnostic
---
pylabrobot/storage/backend.py | 43 ++-------------------------------
pylabrobot/storage/incubator.py | 19 ---------------
2 files changed, 2 insertions(+), 60 deletions(-)
diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py
index 436f32c588f..3eb69aefaaf 100644
--- a/pylabrobot/storage/backend.py
+++ b/pylabrobot/storage/backend.py
@@ -28,11 +28,11 @@ async def close_door(self):
pass
@abstractmethod
- async def fetch_plate_to_loading_tray(self, plate: Plate, **kwargs):
+ async def fetch_plate_to_loading_tray(self, plate: Plate):
pass
@abstractmethod
- async def take_in_plate(self, plate: Plate, site: PlateHolder, **kwargs):
+ async def take_in_plate(self, plate: Plate, site: PlateHolder):
pass
@abstractmethod
@@ -46,92 +46,53 @@ async def get_temperature(self) -> float:
@abstractmethod
async def start_shaking(self, frequency: float):
"""Start shaking the incubator at the given frequency in Hz."""
- pass
@abstractmethod
async def stop_shaking(self):
pass
- """ Methods added for Liconic incubator options."""
-
@abstractmethod
async def get_target_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_target_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_target_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_target_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
-
- @abstractmethod
- async def scan_barcode(self, site: PlateHolder) -> Barcode:
- """Scan barcode at given position."""
- pass
@abstractmethod
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 bb691b678cf..9c2b56a1eb3 100644
--- a/pylabrobot/storage/incubator.py
+++ b/pylabrobot/storage/incubator.py
@@ -210,9 +210,6 @@ def deserialize(cls, data: dict, allow_marshal: bool = False):
model=data["model"],
)
- async def scan_barcode(self, site: PlateHolder):
- return await self.backend.scan_barcode(site)
-
async def get_target_temperature(self) -> float:
"""Get the set value temperature of the incubator in degrees Celsius."""
return await self.backend.get_target_temperature()
@@ -253,22 +250,6 @@ async def get_target_n2_level(self) -> float:
"""Get the set value N2 level of the incubator in percentage (%)."""
return await self.backend.get_target_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()
-
async def move_position_to_position(
self, plate_name: str, dest_site: PlateHolder, **backend_kwargs
) -> Plate:
From f7233997519b6e016ab8315999d264f31cbb2f00 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:25:00 -0800
Subject: [PATCH 39/56] use fractions instead of percentages for humidity, CO2,
and N2 levels
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/backend.py | 18 ++++-----
pylabrobot/storage/incubator.py | 18 ++++-----
pylabrobot/storage/liconic/liconic_backend.py | 37 ++++++++++---------
3 files changed, 37 insertions(+), 36 deletions(-)
diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py
index 3eb69aefaaf..07153909651 100644
--- a/pylabrobot/storage/backend.py
+++ b/pylabrobot/storage/backend.py
@@ -57,39 +57,39 @@ async def get_target_temperature(self) -> float:
@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 as a fraction; e.g. 0.9 for 90% RH."""
@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 as a fraction; e.g. 0.9 for 90% RH."""
@abstractmethod
async def get_target_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 as a fraction; e.g. 0.9 for 90% RH."""
@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 as a fraction; e.g. 0.05 for 5%."""
@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 as a fraction; e.g. 0.05 for 5%."""
@abstractmethod
async def get_target_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 as a fraction; e.g. 0.05 for 5%."""
@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 as a fraction; e.g. 0.9 for 90%."""
@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 as a fraction; e.g. 0.9 for 90%."""
@abstractmethod
async def get_target_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 as a fraction; e.g. 0.9 for 90%."""
@abstractmethod
async def move_position_to_position(
diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py
index 9c2b56a1eb3..a07de473b4a 100644
--- a/pylabrobot/storage/incubator.py
+++ b/pylabrobot/storage/incubator.py
@@ -215,39 +215,39 @@ async def get_target_temperature(self) -> float:
return await self.backend.get_target_temperature()
async def set_humidity(self, humidity: float):
- """Set the humidity of the incubator in percentage (%)."""
+ """Set the humidity of the incubator as a fraction (0.0 to 1.0)."""
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 as a fraction (0.0 to 1.0)."""
return await self.backend.get_humidity()
async def get_target_humidity(self) -> float:
- """Get the set value humidity of the incubator in percentage (%)."""
+ """Get the set value humidity of the incubator as a fraction (0.0 to 1.0)."""
return await self.backend.get_target_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 as a fraction (0.0 to 1.0)."""
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 as a fraction (0.0 to 1.0)."""
return await self.backend.get_co2_level()
async def get_target_co2_level(self) -> float:
- """Get the set value CO2 level of the incubator in percentage (%)."""
+ """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0)."""
return await self.backend.get_target_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 as a fraction (0.0 to 1.0)."""
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 as a fraction (0.0 to 1.0)."""
return await self.backend.get_n2_level()
async def get_target_n2_level(self) -> float:
- """Get the set value N2 level of the incubator in percentage (%)."""
+ """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0)."""
return await self.backend.get_target_n2_level()
async def move_position_to_position(
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index a749c261fa4..2c9ea1eb92d 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -391,93 +391,94 @@ async def get_target_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 (%)."""
+ """Set the humidity of the incubator as a fraction (0.0 to 1.0)."""
if self.model.value.split("_")[-1] == "NC":
raise NotImplementedError("Climate control is not supported on this model")
- humidity_val = int(humidity * 10)
+ humidity_val = int(humidity * 1000) # PLC uses 0.1% units: 0.9 fraction -> 900 -> 90.0%
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 (%)."""
+ """Get the actual humidity of the incubator as a fraction (0.0 to 1.0)."""
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)
- humidity = humidity_value / 10.0
+ humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction
return humidity
except ValueError:
raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}")
async def get_target_humidity(self) -> float:
- """Get the set value humidity of the incubator in percentage (%)."""
+ """Get the set value humidity of the incubator as a fraction (0.0 to 1.0)."""
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)
- humidity = humidity_value / 10.0
+ humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction
return humidity
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)
+ """Set the CO2 level of the incubator as a fraction (0.0 to 1.0). PLC uses 1/100% vol units
+ (e.g. 500 = 5.0%), so 0.05 fraction -> 500."""
+ co2_val = int(co2_level * 10000) # PLC uses 0.01% units: 0.05 fraction -> 500 -> 5.0%
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 as a fraction (0.0 to 1.0)."""
resp = await self._send_command_plc("RD DM984")
try:
co2_value = int(resp)
- co2 = co2_value / 100.0
+ co2 = co2_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction
return co2
except ValueError:
raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}")
# UNTESTED
async def get_target_co2_level(self) -> float:
- """Get the set value CO2 level of the incubator in percentage (%)."""
+ """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0)."""
resp = await self._send_command_plc("RD DM894")
try:
co2_set_value = int(resp)
- co2 = co2_set_value / 100.0
+ co2 = co2_set_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction
return co2
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)
+ """Set the N2 level of the incubator as a fraction (0.0 to 1.0)."""
+ n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0%
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 as a fraction (0.0 to 1.0)."""
resp = await self._send_command_plc("RD DM985")
try:
n2_value = int(resp)
- n2 = n2_value / 100.0
+ n2 = n2_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction
return n2
except ValueError:
raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}")
# UNTESTED
async def get_target_n2_level(self) -> float:
- """Get the set value N2 level of the incubator in percentage (%)."""
+ """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0)."""
resp = await self._send_command_plc("RD DM895")
try:
n2_set_value = int(resp)
- n2 = n2_set_value / 100.0
+ n2 = n2_set_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction
return n2
except ValueError:
raise RuntimeError(f"Invalid N2 set value received from incubator: {resp!r}")
From 028b57b8464c03b443b199b20ca0821281e75ee6 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:27:44 -0800
Subject: [PATCH 40/56] io_plc -> io, _send_command_plc -> _send_command
---
pylabrobot/storage/liconic/liconic_backend.py | 164 +++++++++---------
1 file changed, 82 insertions(+), 82 deletions(-)
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 2c9ea1eb92d..268ce9a0dcb 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -66,7 +66,7 @@ def __init__(
self.model = model
self._racks: List[PlateCarrier] = []
- self.io_plc = Serial(
+ self.io = Serial(
port=port,
baudrate=self.default_baud,
bytesize=serial.EIGHTBITS,
@@ -90,40 +90,40 @@ async def setup(self):
5. Poll ready-flag: RD 1915 → wait for "1"
"""
try:
- await self.io_plc.setup()
+ await self.io.setup()
except serial.SerialException as e:
- raise RuntimeError(f"Could not open {self.io_plc.port}: {e}")
+ raise RuntimeError(f"Could not open {self.io.port}: {e}") from e
- await self.io_plc.send_break(duration=0.2) # >100 ms required
+ await self.io.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.reset_input_buffer()
+ await self.io.reset_output_buffer()
- await self.io_plc.write(b"CR\r")
+ await self.io.write(b"CR\r")
deadline = time.time() + self.init_timeout
while time.time() < deadline:
- resp = await self.io_plc.readline() # reads through LF
+ resp = await self.io.readline() # reads through LF
if resp.strip() == b"CC":
break
else:
- await self.io_plc.stop()
+ await self.io.stop()
raise TimeoutError(f"No CC response from Liconic PLC within {self.init_timeout} seconds")
- await self.io_plc.write(b"ST 1801\r")
- resp = await self.io_plc.readline()
+ await self.io.write(b"ST 1801\r")
+ resp = await self.io.readline()
if resp.strip() != b"OK":
- await self.io_plc.stop()
+ 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()
+ await self.io.write(b"RD 1915\r")
+ flag = await self.io.readline()
if flag.strip() == b"1":
break
await asyncio.sleep(self.poll_interval)
else:
- await self.io_plc.stop()
+ await self.io.stop()
raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds")
def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]:
@@ -151,23 +151,23 @@ def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]:
)
async def stop(self):
- await self.io_plc.stop()
+ await self.io.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._send_command("ST 1900")
+ await self._send_command("ST 1801")
await self._wait_ready()
async def open_door(self):
- await self._send_command_plc("ST 1901")
+ await self._send_command("ST 1901")
await self._wait_ready()
async def close_door(self):
- await self._send_command_plc("ST 1902")
+ await self._send_command("ST 1902")
await self._wait_ready()
async def fetch_plate_to_loading_tray(self, plate: Plate, read_barcode: bool = False):
@@ -178,34 +178,34 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, read_barcode: bool = F
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(f"WR DM0 {m}") # carousel number
+ await self._send_command(f"WR DM23 {step_size}") # motor step size
+ await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette
+ await self._send_command(f"WR DM5 {n}") # plate position in carousel
if read_barcode:
plate.barcode = await self.read_barcode_inline(m, n)
- await self._send_command_plc("ST 1905") # plate to transfer station
+ await self._send_command("ST 1905") # plate to transfer station
await self._wait_ready()
- await self._send_command_plc("ST 1903") # terminate access
+ await self._send_command("ST 1903") # terminate access
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)
- 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._send_command(f"WR DM0 {m}") # carousel number
+ await self._send_command(f"WR DM23 {step_size}") # motor step size
+ await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette
+ await self._send_command(f"WR DM5 {n}") # plate position in cassette
+ await self._send_command("ST 1904") # plate from transfer station
await self._wait_ready()
if read_barcode:
plate.barcode = await self.read_barcode_inline(m, n)
- await self._send_command_plc("ST 1903") # terminate access
+ await self._send_command("ST 1903") # terminate access
async def move_position_to_position(
self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False
@@ -221,56 +221,56 @@ async def move_position_to_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(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(f"WR DM0 {orig_m}") # carousel number
+ await self._send_command(f"WR DM23 {orig_step_size}") # motor step size
+ await self._send_command(f"WR DM25 {orig_pos_num}") # number of positions in cassette
+ await self._send_command(f"WR DM5 {orig_n}") # origin plate position #
if read_barcode:
plate.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("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(f"WR DM0 {dest_m}") # destination cassette # if different
+ await self._send_command(f"WR DM23 {dest_step_size}") # motor step size
+ await self._send_command(f"WR DM25 {dest_pos_num}") # number of positions in cassette
+ await self._send_command(f"WR DM5 {dest_n}") # destination plate position #
+ await self._send_command("ST 1909") # place plate in destination position
await self._wait_ready()
- await self._send_command_plc("ST 1903") # terminate access
+ await self._send_command("ST 1903") # terminate access
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._send_command("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
+ reset = await self._send_command("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:
+ async def _send_command(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)
+ await self.io.write(cmd.encode(self.serial_message_encoding))
+ resp = (await self.io.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()
@@ -289,7 +289,7 @@ async def _wait_plate_ready(self, timeout: int = 60):
start = time.time()
deadline = start + timeout
while time.time() < deadline:
- resp = await self._send_command_plc("RD 1914")
+ resp = await self._send_command("RD 1914")
if resp == "1":
return
await asyncio.sleep(0.1)
@@ -303,13 +303,13 @@ async def _wait_ready(self, timeout: int = 60):
start = time.time()
deadline = start + timeout
while time.time() < deadline:
- resp = await self._send_command_plc("RD 1915")
+ resp = await self._send_command("RD 1915")
if resp == "1":
return
await asyncio.sleep(0.1)
- err_flag = await self._send_command_plc("RD 1814")
+ err_flag = await self._send_command("RD 1814")
if err_flag == "1":
- error = await self._send_command_plc("RD DM200")
+ error = await self._send_command("RD DM200")
for member in HandlingError:
if error == member.value:
raise handler_error_map[member]
@@ -324,7 +324,7 @@ async def set_temperature(self, temperature: float):
temp_value = int(temperature * 10)
temp_str = str(temp_value).zfill(5)
- await self._send_command_plc(f"WR DM890 {temp_str}")
+ await self._send_command(f"WR DM890 {temp_str}")
await self._wait_ready()
async def get_temperature(self) -> float:
@@ -332,7 +332,7 @@ async def get_temperature(self) -> float:
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")
+ resp = await self._send_command("RD DM982")
try:
temp_value = int(resp)
temperature = temp_value / 10.0
@@ -352,7 +352,7 @@ async def shaker_status(self) -> int:
# 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_val = await self._send_command("RD DM39")
speed = int(speed_val) / 10.0
await self._wait_ready()
return speed
@@ -367,14 +367,14 @@ async def start_shaking(self, frequency):
else:
frequency_value = int(frequency) # assuming incubator expects frequency in 0.1 Hz units
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._send_command(f"WR DM39 {str(frequency).zfill(5)}")
+ await self._send_command("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")
+ await self._send_command("RS 1913")
await self._wait_ready()
async def get_target_temperature(self) -> float:
@@ -382,7 +382,7 @@ async def get_target_temperature(self) -> float:
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")
+ resp = await self._send_command("RD DM890")
try:
temp_value = int(resp)
temperature = temp_value / 10.0
@@ -396,7 +396,7 @@ async def set_humidity(self, humidity: float):
raise NotImplementedError("Climate control is not supported on this model")
humidity_val = int(humidity * 1000) # PLC uses 0.1% units: 0.9 fraction -> 900 -> 90.0%
- await self._send_command_plc(f"WR DM893 {str(humidity_val).zfill(5)}")
+ await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}")
await self._wait_ready()
async def get_humidity(self) -> float:
@@ -404,7 +404,7 @@ async def get_humidity(self) -> float:
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")
+ resp = await self._send_command("RD DM983")
try:
humidity_value = int(resp)
humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction
@@ -417,7 +417,7 @@ async def get_target_humidity(self) -> float:
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")
+ resp = await self._send_command("RD DM893")
try:
humidity_value = int(resp)
humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction
@@ -430,13 +430,13 @@ async def set_co2_level(self, co2_level: float):
"""Set the CO2 level of the incubator as a fraction (0.0 to 1.0). PLC uses 1/100% vol units
(e.g. 500 = 5.0%), so 0.05 fraction -> 500."""
co2_val = int(co2_level * 10000) # PLC uses 0.01% units: 0.05 fraction -> 500 -> 5.0%
- await self._send_command_plc(f"WR DM894 {str(co2_val).zfill(5)}")
+ await self._send_command(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 as a fraction (0.0 to 1.0)."""
- resp = await self._send_command_plc("RD DM984")
+ resp = await self._send_command("RD DM984")
try:
co2_value = int(resp)
co2 = co2_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction
@@ -447,7 +447,7 @@ async def get_co2_level(self) -> float:
# UNTESTED
async def get_target_co2_level(self) -> float:
"""Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0)."""
- resp = await self._send_command_plc("RD DM894")
+ resp = await self._send_command("RD DM894")
try:
co2_set_value = int(resp)
co2 = co2_set_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction
@@ -459,12 +459,12 @@ async def get_target_co2_level(self) -> float:
async def set_n2_level(self, n2_level: float):
"""Set the N2 level of the incubator as a fraction (0.0 to 1.0)."""
n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0%
- await self._send_command_plc(f"WR DM895 {str(n2_val).zfill(5)}")
+ await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}")
# UNTESTED
async def get_n2_level(self) -> float:
"""Get the N2 level of the incubator as a fraction (0.0 to 1.0)."""
- resp = await self._send_command_plc("RD DM985")
+ resp = await self._send_command("RD DM985")
try:
n2_value = int(resp)
n2 = n2_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction
@@ -475,7 +475,7 @@ async def get_n2_level(self) -> float:
# UNTESTED
async def get_target_n2_level(self) -> float:
"""Get the set value N2 level of the incubator as a fraction (0.0 to 1.0)."""
- resp = await self._send_command_plc("RD DM895")
+ resp = await self._send_command("RD DM895")
try:
n2_set_value = int(resp)
n2 = n2_set_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction
@@ -488,20 +488,20 @@ async def get_target_n2_level(self) -> float:
# 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")
+ resp = await self._send_command("RD 1912")
if home and resp == "1":
- await self._send_command_plc("RS 1912")
+ await self._send_command("RS 1912")
else:
- await self._send_command_plc("ST 1912")
+ await self._send_command("ST 1912")
# 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."""
- await self._send_command_plc("ST 1911")
+ await self._send_command("ST 1911")
await asyncio.sleep(0.1)
- resp = await self._send_command_plc("RD 1812")
+ resp = await self._send_command("RD 1812")
if resp == "1":
return True
elif resp == "0":
@@ -512,7 +512,7 @@ async def check_shovel_sensor(self) -> bool:
# UNTESTED
async def check_transfer_sensor(self) -> bool:
"""Check if the transfer plate sensor is activated."""
- resp = await self._send_command_plc("RD 1813")
+ resp = await self._send_command("RD 1813")
if resp == "1":
return True
elif resp == "0":
@@ -523,7 +523,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."""
- resp = await self._send_command_plc("RD 1807")
+ resp = await self._send_command("RD 1807")
if resp == "1":
return True
elif resp == "0":
@@ -539,11 +539,11 @@ async def scan_barcode(self, site: PlateHolder) -> Barcode:
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
+ await self._send_command(f"WR DM0 {m}") # carousel number
+ await self._send_command(f"WR DM23 {step_size}") # pitch of plate in mm
+ await self._send_command(f"WR DM25 {pos_num}") # plate
+ await self._send_command(f"WR DM5 {n}") # plate position in carousel
+ await self._send_command("ST 1910") # move shovel to barcode reading position
barcode = await self.barcode_scanner.scan()
logger.info(f"Scanned barcode: {barcode.data}")
@@ -552,7 +552,7 @@ async def scan_barcode(self, site: PlateHolder) -> Barcode:
def serialize(self) -> dict:
return {
**super().serialize(),
- "port": self.io_plc.port,
+ "port": self.io.port,
}
@classmethod
From 4122ed04019abe64624d753b458664101f24947e Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:30:22 -0800
Subject: [PATCH 41/56] move UNTESTED and doc comments into docstrings in
LiconicBackend
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/liconic_backend.py | 82 ++++++++++---------
1 file changed, 45 insertions(+), 37 deletions(-)
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 268ce9a0dcb..1e8e5a5ca51 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -340,28 +340,27 @@ async def get_temperature(self) -> float:
except ValueError:
raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}")
- # 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).
+
+ UNTESTED. Unsure if 1 means ON and 0 means OFF, needs to be confirmed."""
# 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.
- # Should be in Hz
async def get_shaker_speed(self) -> float:
- """Gets the current shaker speed default = 25"""
+ """Gets the current shaker speed in Hz, default = 25.
+
+ UNTESTED. Unsure if Liconic returns 00250 for 25 or 00025. Assuming former."""
speed_val = await self._send_command("RD DM39")
speed = int(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."""
+ """Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Uses command
+ ST 1913.
+
+ UNTESTED. Unsure if WR DM39 00250 sets 25 Hz or if WR DM39 00025 does. Assuming former."""
if frequency < 1.0 or frequency > 50.0:
raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz")
else:
@@ -371,9 +370,10 @@ async def start_shaking(self, frequency):
await self._send_command("ST 1913")
await self._wait_ready()
- # UNTESTED
async def stop_shaking(self):
- """Stop shaking. Using command RS 1913"""
+ """Stop shaking. Uses command RS 1913.
+
+ UNTESTED."""
await self._send_command("RS 1913")
await self._wait_ready()
@@ -425,17 +425,19 @@ async def get_target_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 as a fraction (0.0 to 1.0). PLC uses 1/100% vol units
- (e.g. 500 = 5.0%), so 0.05 fraction -> 500."""
+ (e.g. 500 = 5.0%), so 0.05 fraction -> 500.
+
+ UNTESTED."""
co2_val = int(co2_level * 10000) # PLC uses 0.01% units: 0.05 fraction -> 500 -> 5.0%
await self._send_command(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 as a fraction (0.0 to 1.0)."""
+ """Get the CO2 level of the incubator as a fraction (0.0 to 1.0).
+
+ UNTESTED."""
resp = await self._send_command("RD DM984")
try:
co2_value = int(resp)
@@ -444,9 +446,10 @@ async def get_co2_level(self) -> float:
except ValueError:
raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}")
- # UNTESTED
async def get_target_co2_level(self) -> float:
- """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0)."""
+ """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0).
+
+ UNTESTED."""
resp = await self._send_command("RD DM894")
try:
co2_set_value = int(resp)
@@ -455,15 +458,17 @@ async def get_target_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 as a fraction (0.0 to 1.0)."""
+ """Set the N2 level of the incubator as a fraction (0.0 to 1.0).
+
+ UNTESTED."""
n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0%
await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}")
- # UNTESTED
async def get_n2_level(self) -> float:
- """Get the N2 level of the incubator as a fraction (0.0 to 1.0)."""
+ """Get the N2 level of the incubator as a fraction (0.0 to 1.0).
+
+ UNTESTED."""
resp = await self._send_command("RD DM985")
try:
n2_value = int(resp)
@@ -472,9 +477,10 @@ async def get_n2_level(self) -> float:
except ValueError:
raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}")
- # UNTESTED
async def get_target_n2_level(self) -> float:
- """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0)."""
+ """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0).
+
+ UNTESTED."""
resp = await self._send_command("RD DM895")
try:
n2_set_value = int(resp)
@@ -483,22 +489,22 @@ async def get_target_n2_level(self) -> float:
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?)
- # 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.
+
+ 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."""
resp = await self._send_command("RD 1912")
if home and resp == "1":
await self._send_command("RS 1912")
else:
await self._send_command("ST 1912")
- # 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."""
+ """Activate shovel transfer sensor (ST 1911, off by default on HT units), wait 0.1 seconds,
+ then check if the shovel plate sensor is activated.
+
+ UNTESTED."""
await self._send_command("ST 1911")
await asyncio.sleep(0.1)
resp = await self._send_command("RD 1812")
@@ -509,9 +515,10 @@ async def check_shovel_sensor(self) -> bool:
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."""
+ """Check if the transfer plate sensor is activated.
+
+ UNTESTED."""
resp = await self._send_command("RD 1813")
if resp == "1":
return True
@@ -520,9 +527,10 @@ async def check_transfer_sensor(self) -> bool:
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."""
+ """Check if the second transfer plate sensor is activated.
+
+ UNTESTED."""
resp = await self._send_command("RD 1807")
if resp == "1":
return True
From d8ee95d7f3e258dde3402794c348e6767ce8c280 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:38:40 -0800
Subject: [PATCH 42/56] fix copy-paste bugs in Liconic constants and backend
- STX280_DH enum value was "STX44_DH" instead of "STX280_DH"
- PLATE_SET_GENERAL_HANDLING_ERROR was 6 digits ("000601") instead of 5 ("00601")
- Missing closing quote in ValueError f-string
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/constants.py | 4 ++--
pylabrobot/storage/liconic/liconic_backend.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py
index 73617096d96..865b589a04d 100644
--- a/pylabrobot/storage/liconic/constants.py
+++ b/pylabrobot/storage/liconic/constants.py
@@ -40,7 +40,7 @@ class LiconicType(Enum):
STX280_AR = "STX280_AR" # humidity controlled
STX280_DF = "STX280_DF" # deep freezer
STX280_NC = "STX280_NC" # no climate
- STX280_DH = "STX44_DH" # dry humid
+ STX280_DH = "STX280_DH" # dry humid
STX500_IC = "STX500_IC" # incubator
STX500_HC = "STX500_HC" # humid cooler
@@ -151,7 +151,7 @@ class HandlingError(Enum):
PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION = "00516"
PLATE_PLACE_NO_RECOVERY = "00517"
- PLATE_SET_GENERAL_HANDLING_ERROR = "000601"
+ PLATE_SET_GENERAL_HANDLING_ERROR = "00601"
PLATE_SET_GATE_OPEN_ERROR = "00607"
PLATE_SET_GATE_CLOSE_ERROR = "00608"
PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR = "00609"
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 1e8e5a5ca51..12328d3be20 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -61,7 +61,7 @@ def __init__(
try:
model = LiconicType(model)
except ValueError:
- raise ValueError(f"Unsupported Liconic model: '{model}")
+ raise ValueError(f"Unsupported Liconic model: '{model}'")
self.model = model
self._racks: List[PlateCarrier] = []
From 8b656aa6ef3822dbe13d1934302377b274d07718 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:39:20 -0800
Subject: [PATCH 43/56] raise on unknown site height instead of silently
returning None
dict.get() returns None for unknown heights, which would send
"WR DM23 None" over the serial bus.
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/liconic_backend.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 12328d3be20..44f886a783c 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -145,7 +145,11 @@ def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]:
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
+ if site_height not in LICONIC_SITE_HEIGHT_TO_STEPS:
+ raise ValueError(
+ f"Unknown site height {site_height}mm - not in LICONIC_SITE_HEIGHT_TO_STEPS"
+ )
+ return LICONIC_SITE_HEIGHT_TO_STEPS[site_height], site_num
raise ValueError(
f"Could not parse site height and pos num from PlateCarrier model: {rack.model}"
)
From 64d65dfc00144e5c4707ba4941899eb30fa11f06 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:39:35 -0800
Subject: [PATCH 44/56] fix serialize/deserialize to include model parameter
deserialize() was missing the required 'model' argument, crashing on
every call. serialize() also wasn't persisting it.
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/liconic_backend.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 44f886a783c..74e6df7be48 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -565,8 +565,9 @@ def serialize(self) -> dict:
return {
**super().serialize(),
"port": self.io.port,
+ "model": self.model.value,
}
@classmethod
def deserialize(cls, data: dict):
- return cls(port=data["port"])
+ return cls(port=data["port"], model=data["model"])
From 787735a1037a14a6aaaca3c91b8e6412d866d67a Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Thu, 5 Feb 2026 16:40:30 -0800
Subject: [PATCH 45/56] fix shaking frequency conversion and missing
_wait_ready in set_n2
- start_shaking: int(freq) * 10 truncates fractional Hz; use int(freq * 10)
- set_n2_level: add missing _wait_ready() consistent with all other set_* methods
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/liconic_backend.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 74e6df7be48..f60cef59cea 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -367,10 +367,8 @@ async def start_shaking(self, frequency):
UNTESTED. Unsure if WR DM39 00250 sets 25 Hz or if WR DM39 00025 does. Assuming former."""
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
- frequency = frequency_value * 10
- await self._send_command(f"WR DM39 {str(frequency).zfill(5)}")
+ frequency_value = int(frequency * 10) # PLC expects 0.1 Hz units: 25 Hz -> 250
+ await self._send_command(f"WR DM39 {str(frequency_value).zfill(5)}")
await self._send_command("ST 1913")
await self._wait_ready()
@@ -468,6 +466,7 @@ async def set_n2_level(self, n2_level: float):
UNTESTED."""
n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0%
await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}")
+ await self._wait_ready()
async def get_n2_level(self) -> float:
"""Get the N2 level of the incubator as a fraction (0.0 to 1.0).
From 088755e6a504c0bccd41eb445e8c80f1b626b3aa Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Fri, 6 Feb 2026 05:42:43 -0800
Subject: [PATCH 46/56] remove dead code from KeyenceBarcodeScannerBackend
- Remove send_command_and_stream() (defined but never called)
- Remove unused imports (Awaitable, Callable, Optional)
- Remove duplicate RMOTOR call before poll loop
Co-Authored-By: Claude Opus 4.6
---
.../keyence/keyence_backend.py | 33 -------------------
1 file changed, 33 deletions(-)
diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
index 89b9761e25c..c54c12f6171 100644
--- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -1,7 +1,6 @@
import asyncio
import logging
import time
-from typing import Awaitable, Callable, Optional
import serial
@@ -47,8 +46,6 @@ async def setup(self):
async def initialize(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")
@@ -71,36 +68,6 @@ async def send_command(self, command: str) -> str:
response = await self.io.read()
return response.decode(self.serial_messaging_encoding).strip()
- async def send_command_and_stream(
- self,
- command: str,
- on_response: Callable[[str], Awaitable[None]],
- timeout: float = 5.0,
- 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))
- 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()
- if decoded:
- try:
- await on_response(decoded) # Call the callback
- except Exception as e:
- logger.error(f"Error in on_response callback: {e}", exc_info=True)
- if stop_condition and stop_condition(decoded):
- break
- except asyncio.TimeoutError:
- logger.warning("Timeout while waiting for barcode scanner response.")
- continue
- except Exception as e:
- logger.error(f"Error while reading from barcode scanner: {e}", exc_info=True)
- continue
-
async def stop(self):
await self.io.stop()
From 0553e031ed25d98bf7ed18d521e4687b0dba20b6 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Fri, 6 Feb 2026 05:43:23 -0800
Subject: [PATCH 47/56] simplify rack definitions: remove unused params, add
docstrings
Remove unused pitch, steps, bicarousel parameters from _liconic_rack
and all callers. Move hardware specs (pitch, motor steps, bicarousel)
into docstrings for documentation. Remove Optional[] on params with
non-None defaults.
Co-Authored-By: Claude Opus 4.6
---
.../storage/liconic/liconic_backend_tests.py | 259 ++++++++++++++++++
pylabrobot/storage/liconic/racks.py | 173 ++++--------
2 files changed, 308 insertions(+), 124 deletions(-)
create mode 100644 pylabrobot/storage/liconic/liconic_backend_tests.py
diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py
new file mode 100644
index 00000000000..4e130bdb8c9
--- /dev/null
+++ b/pylabrobot/storage/liconic/liconic_backend_tests.py
@@ -0,0 +1,259 @@
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from pylabrobot.resources import Plate, PlateHolder
+from pylabrobot.resources.carrier import PlateCarrier
+from pylabrobot.storage.liconic.constants import LiconicType
+from pylabrobot.storage.liconic.liconic_backend import LICONIC_SITE_HEIGHT_TO_STEPS, LiconicBackend
+from pylabrobot.storage.liconic.racks import (
+ liconic_rack_5mm_42,
+ liconic_rack_17mm_22,
+ liconic_rack_44mm_10,
+ liconic_rack_104mm_4,
+)
+
+
+class TestStepSizeFormula(unittest.TestCase):
+ """Verify that the motor step sizes follow the documented formula:
+ steps = round(pitch * 1713 / 50), where pitch = site_height + 6."""
+
+ def test_step_sizes_match_formula(self):
+ for site_height, claimed_steps in LICONIC_SITE_HEIGHT_TO_STEPS.items():
+ if site_height == 104:
+ continue # see test_104mm_step_size_deviates_from_formula
+ pitch = site_height + 6
+ computed = round(pitch * 1713 / 50)
+ with self.subTest(site_height=site_height, pitch=pitch):
+ self.assertEqual(
+ claimed_steps,
+ computed,
+ msg=f"site_height={site_height}, pitch={pitch}: "
+ f"claimed {claimed_steps}, formula gives {computed}",
+ )
+
+ def test_104mm_step_size_deviates_from_formula(self):
+ """The 104mm entry (pitch 110mm) claims 3563 steps but the formula gives 3769.
+ This is either empirically determined or a data entry error.
+ TODO: verify with hardware."""
+ pitch = 110
+ computed = round(pitch * 1713 / 50)
+ self.assertEqual(computed, 3769)
+ self.assertEqual(LICONIC_SITE_HEIGHT_TO_STEPS[104], 3563)
+ self.assertNotEqual(LICONIC_SITE_HEIGHT_TO_STEPS[104], computed)
+
+ def test_known_reference_points(self):
+ """The two reference points from the Liconic documentation."""
+ self.assertEqual(LICONIC_SITE_HEIGHT_TO_STEPS[17], 788) # pitch 23mm
+ self.assertEqual(LICONIC_SITE_HEIGHT_TO_STEPS[44], 1713) # pitch 50mm
+
+
+class TestRackConstruction(unittest.TestCase):
+ def test_rack_site_count(self):
+ rack = liconic_rack_17mm_22("test_rack")
+ self.assertEqual(len(rack.sites), 22)
+
+ def test_rack_model_name(self):
+ rack = liconic_rack_17mm_22("test_rack")
+ self.assertEqual(rack.model, "liconic_rack_17mm_22")
+
+ def test_rack_site_height(self):
+ rack = liconic_rack_17mm_22("test_rack")
+ # All sites except the last should have size_z == site_height
+ for i in range(21):
+ self.assertEqual(rack.sites[i].get_size_z(), 17)
+
+ def test_rack_default_total_height(self):
+ rack = liconic_rack_17mm_22("test_rack")
+ self.assertEqual(rack.get_size_z(), 505)
+
+ def test_rack_custom_total_height(self):
+ rack = liconic_rack_5mm_42("test_rack")
+ self.assertEqual(rack.get_size_z(), 505)
+
+
+class TestCarrierToStepsPos(unittest.TestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+
+ def test_parses_model_name(self):
+ rack = liconic_rack_17mm_22("test_rack")
+ self.backend._racks = [rack]
+ site = rack.sites[0]
+ steps, pos_num = self.backend._carrier_to_steps_pos(site)
+ self.assertEqual(steps, 788)
+ self.assertEqual(pos_num, 22)
+
+ def test_5mm_rack(self):
+ rack = liconic_rack_5mm_42("test_rack")
+ self.backend._racks = [rack]
+ site = rack.sites[0]
+ steps, pos_num = self.backend._carrier_to_steps_pos(site)
+ self.assertEqual(steps, 377)
+ self.assertEqual(pos_num, 42)
+
+ def test_44mm_rack(self):
+ rack = liconic_rack_44mm_10("test_rack")
+ self.backend._racks = [rack]
+ site = rack.sites[0]
+ steps, pos_num = self.backend._carrier_to_steps_pos(site)
+ self.assertEqual(steps, 1713)
+ self.assertEqual(pos_num, 10)
+
+ def test_unknown_model_raises(self):
+ rack = PlateCarrier(
+ name="bad_rack",
+ size_x=100,
+ size_y=100,
+ size_z=500,
+ sites={},
+ model="some_other_rack_17mm_22",
+ )
+ self.backend._racks = [rack]
+ site = PlateHolder(name="s", size_x=10, size_y=10, size_z=10, pedestal_size_z=0)
+ rack.assign_child_resource(site, location=None)
+ with self.assertRaises(ValueError):
+ self.backend._carrier_to_steps_pos(site)
+
+
+class TestSiteToMN(unittest.TestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+
+ def test_first_rack_first_site(self):
+ rack = liconic_rack_17mm_22("rack1")
+ self.backend._racks = [rack]
+ m, n = self.backend._site_to_m_n(rack.sites[0])
+ self.assertEqual(m, 1) # 1-indexed rack
+ self.assertEqual(n, 1) # 1-indexed site
+
+ def test_first_rack_last_site(self):
+ rack = liconic_rack_17mm_22("rack1")
+ self.backend._racks = [rack]
+ m, n = self.backend._site_to_m_n(rack.sites[21])
+ self.assertEqual(m, 1)
+ self.assertEqual(n, 22)
+
+ def test_second_rack(self):
+ rack1 = liconic_rack_17mm_22("rack1")
+ rack2 = liconic_rack_17mm_22("rack2")
+ self.backend._racks = [rack1, rack2]
+ m, n = self.backend._site_to_m_n(rack2.sites[0])
+ self.assertEqual(m, 2)
+ self.assertEqual(n, 1)
+
+
+class TestValueConversions(unittest.IsolatedAsyncioTestCase):
+ """Test the PLC register value conversions without actual serial IO."""
+
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._send_command = AsyncMock(return_value="OK")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_set_temperature_conversion(self):
+ """37.5°C should become '00375' (0.1°C units)."""
+ await self.backend.set_temperature(37.5)
+ self.backend._send_command.assert_any_call("WR DM890 00375")
+
+ async def test_get_temperature_conversion(self):
+ """PLC value 370 should return 37.0°C."""
+ self.backend._send_command = AsyncMock(return_value="370")
+ result = await self.backend.get_temperature()
+ self.assertAlmostEqual(result, 37.0)
+
+ async def test_set_humidity_conversion(self):
+ """0.9 fraction should become '00900' (0.1% units)."""
+ await self.backend.set_humidity(0.9)
+ self.backend._send_command.assert_any_call("WR DM893 00900")
+
+ async def test_get_humidity_conversion(self):
+ """PLC value 900 should return 0.9 fraction."""
+ self.backend._send_command = AsyncMock(return_value="900")
+ result = await self.backend.get_humidity()
+ self.assertAlmostEqual(result, 0.9)
+
+ async def test_set_co2_conversion(self):
+ """0.05 fraction (5%) should become '00500' (0.01% units)."""
+ await self.backend.set_co2_level(0.05)
+ self.backend._send_command.assert_any_call("WR DM894 00500")
+
+ async def test_get_co2_conversion(self):
+ """PLC value 500 should return 0.05 fraction."""
+ self.backend._send_command = AsyncMock(return_value="500")
+ result = await self.backend.get_co2_level()
+ self.assertAlmostEqual(result, 0.05)
+
+ async def test_set_n2_conversion(self):
+ """0.9 fraction (90%) should become '09000' (0.01% units)."""
+ await self.backend.set_n2_level(0.9)
+ self.backend._send_command.assert_any_call("WR DM895 09000")
+
+ async def test_start_shaking_conversion(self):
+ """25.0 Hz should become '00250' (0.1 Hz units)."""
+ await self.backend.start_shaking(25.0)
+ self.backend._send_command.assert_any_call("WR DM39 00250")
+
+ async def test_start_shaking_fractional(self):
+ """10.5 Hz should become '00105' (0.1 Hz units)."""
+ await self.backend.start_shaking(10.5)
+ self.backend._send_command.assert_any_call("WR DM39 00105")
+
+ async def test_start_shaking_range_low(self):
+ with self.assertRaises(ValueError):
+ await self.backend.start_shaking(0.5)
+
+ async def test_start_shaking_range_high(self):
+ with self.assertRaises(ValueError):
+ await self.backend.start_shaking(51.0)
+
+ async def test_nc_model_rejects_climate(self):
+ backend = LiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
+ with self.assertRaises(NotImplementedError):
+ await backend.set_temperature(37.0)
+ with self.assertRaises(NotImplementedError):
+ await backend.get_temperature()
+ with self.assertRaises(NotImplementedError):
+ await backend.set_humidity(0.5)
+
+
+class TestSerialization(unittest.TestCase):
+ def test_serialize_roundtrip(self):
+ backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/ttyUSB0")
+ data = backend.serialize()
+ self.assertEqual(data["port"], "/dev/ttyUSB0")
+ self.assertEqual(data["model"], "STX44_IC")
+
+ restored = LiconicBackend.deserialize(data)
+ self.assertEqual(restored.io.port, "/dev/ttyUSB0")
+ self.assertEqual(restored.model, LiconicType.STX44_IC)
+
+ def test_deserialize_string_model(self):
+ restored = LiconicBackend.deserialize({"port": "/dev/ttyUSB0", "model": "STX44_IC"})
+ self.assertEqual(restored.model, LiconicType.STX44_IC)
+
+
+class TestErrorHandling(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend.io = AsyncMock()
+
+ async def test_send_command_raises_on_empty_response(self):
+ self.backend.io.read = AsyncMock(return_value=b"")
+ with self.assertRaises(RuntimeError):
+ await self.backend._send_command("RD 1915")
+
+ async def test_send_command_raises_on_controller_error(self):
+ from pylabrobot.storage.liconic.errors import LiconicControllerCommandError
+
+ self.backend.io.read = AsyncMock(return_value=b"E1")
+ with self.assertRaises(LiconicControllerCommandError):
+ await self.backend._send_command("ST 1801")
+
+ async def test_send_command_raises_on_unknown_error(self):
+ self.backend.io.read = AsyncMock(return_value=b"E9")
+ with self.assertRaises(RuntimeError, msg="Unknown error"):
+ await self.backend._send_command("ST 1801")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py
index 0fc340d43b7..4dfbf1f7aa2 100644
--- a/pylabrobot/storage/liconic/racks.py
+++ b/pylabrobot/storage/liconic/racks.py
@@ -1,23 +1,15 @@
-from typing import Optional
-
from pylabrobot.resources import Coordinate
from pylabrobot.resources.carrier import PlateCarrier, PlateHolder
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
+ total_height: int = 505,
):
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
@@ -44,392 +36,325 @@ def _liconic_rack(
)
-""" 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"
- )
+ """STX44. Pitch 11mm, motor steps 377."""
+ return _liconic_rack(name=name, site_height=5, num_sites=42, model="liconic_rack_5mm_42")
def liconic_rack_5mm_55(name: str):
+ """STX500 bicarousel. Pitch 11mm, motor steps 377."""
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):
+ """STX1000 bicarousel. Pitch 11mm, motor steps 377."""
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"
- )
+ """STX44. Pitch 17mm, motor steps 582."""
+ return _liconic_rack(name=name, site_height=11, num_sites=28, model="liconic_rack_11mm_28")
def liconic_rack_11mm_37(name: str):
+ """STX500 bicarousel. Pitch 17mm, motor steps 582."""
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):
+ """STX1000 bicarousel. Pitch 17mm, motor steps 582."""
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"
- )
+ """STX44. Pitch 18mm, motor steps 617."""
+ return _liconic_rack(name=name, site_height=12, num_sites=27, model="liconic_rack_12mm_27")
def liconic_rack_12mm_35(name: str):
+ """STX500 bicarousel. Pitch 18mm, motor steps 617."""
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):
+ """STX1000 bicarousel. Pitch 18mm, motor steps 617."""
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"
- )
+ """STX44. Pitch 23mm, motor steps 788."""
+ return _liconic_rack(name=name, site_height=17, num_sites=22, model="liconic_rack_17mm_22")
def liconic_rack_17mm_28(name: str):
+ """STX500 bicarousel. Pitch 23mm, motor steps 788."""
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):
+ """STX1000 bicarousel. Pitch 23mm, motor steps 788."""
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"
- )
+ """STX44. Pitch 28mm, motor steps 959."""
+ return _liconic_rack(name=name, site_height=22, num_sites=17, model="liconic_rack_22mm_17")
def liconic_rack_22mm_23(name: str):
+ """STX500 bicarousel. Pitch 28mm, motor steps 959."""
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):
+ """STX1000 bicarousel. Pitch 28mm, motor steps 959."""
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"
- )
+ """STX44. Pitch 29mm, motor steps 994."""
+ return _liconic_rack(name=name, site_height=23, num_sites=17, model="liconic_rack_23mm_17")
def liconic_rack_23mm_22(name: str):
+ """STX500 bicarousel. Pitch 29mm, motor steps 994."""
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):
+ """STX1000 bicarousel. Pitch 29mm, motor steps 994."""
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"
- )
+ """STX44. Pitch 30mm, motor steps 1028."""
+ return _liconic_rack(name=name, site_height=24, num_sites=17, model="liconic_rack_24mm_17")
def liconic_rack_24mm_21(name: str):
+ """STX500 bicarousel. Pitch 30mm, motor steps 1028."""
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):
+ """STX1000 bicarousel. Pitch 30mm, motor steps 1028."""
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"
- )
+ """STX44. Pitch 33mm, motor steps 1131."""
+ return _liconic_rack(name=name, site_height=27, num_sites=15, model="liconic_rack_27mm_15")
def liconic_rack_27mm_19(name: str):
+ """STX500 bicarousel. Pitch 33mm, motor steps 1131."""
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):
+ """STX1000 bicarousel. Pitch 33mm, motor steps 1131."""
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"
- )
+ """STX44. Pitch 50mm, motor steps 1713."""
+ return _liconic_rack(name=name, site_height=44, num_sites=10, model="liconic_rack_44mm_10")
def liconic_rack_44mm_13(name: str):
+ """STX500 bicarousel. Pitch 50mm, motor steps 1713."""
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):
+ """STX1000 bicarousel. Pitch 50mm, motor steps 1713."""
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"
- )
+ """STX44. Pitch 59mm, motor steps 2021."""
+ return _liconic_rack(name=name, site_height=53, num_sites=8, model="liconic_rack_53mm_8")
def liconic_rack_53mm_10(name: str):
+ """STX500 bicarousel. Pitch 59mm, motor steps 2021."""
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):
+ """STX1000 bicarousel. Pitch 59mm, motor steps 2021."""
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"
- )
+ """STX44. Pitch 72mm, motor steps 2467."""
+ return _liconic_rack(name=name, site_height=66, num_sites=7, model="liconic_rack_66mm_7")
def liconic_rack_66mm_8(name: str):
+ """STX500 bicarousel. Pitch 72mm, motor steps 2467."""
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):
+ """STX1000 bicarousel. Pitch 72mm, motor steps 2467."""
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"
- )
+ """STX44. Pitch 110mm, motor steps 3563."""
+ return _liconic_rack(name=name, site_height=104, num_sites=4, model="liconic_rack_104mm_4")
def liconic_rack_104mm_5(name: str):
+ """STX500 bicarousel. Pitch 110mm, motor steps 3563."""
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):
+ """STX1000 bicarousel. Pitch 110mm, motor steps 3563."""
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 df0d6821ed17898a0aba5edd7656badd5c99d53d Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Fri, 6 Feb 2026 05:45:25 -0800
Subject: [PATCH 48/56] fix trailing spaces in error messages
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/errors.py | 6 +++---
pylabrobot/storage/liconic/liconic_backend.py | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/storage/liconic/errors.py
index 03e65894d66..2945a9247cc 100644
--- a/pylabrobot/storage/liconic/errors.py
+++ b/pylabrobot/storage/liconic/errors.py
@@ -97,7 +97,7 @@ class LiconicHandlerGeneralError(Exception):
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.STACKER_SLOT_ERROR: LiconicHandlerGeneralError("Stacker slot cannot be reached"),
HandlingError.REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerGeneralError(
"Undefined stacker level has been requested"
),
@@ -105,7 +105,7 @@ class LiconicHandlerGeneralError(Exception):
"Export operation while plate is on transfer station"
),
HandlingError.LIFT_INITIALIZATION_ERROR: LiconicHandlerGeneralError(
- "Lift could not be initialized "
+ "Lift could not be initialized"
),
HandlingError.PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError(
"Trying to load a plate, when a plate is already on the shovel"
@@ -113,7 +113,7 @@ class LiconicHandlerGeneralError(Exception):
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.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."
),
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index f60cef59cea..1f4f66b8210 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -317,7 +317,7 @@ async def _wait_ready(self, timeout: int = 60):
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 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):
From 3df9680a6acfc15e5a0dba5c3dd58a283fcca951 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Tue, 3 Mar 2026 16:51:56 -0800
Subject: [PATCH 49/56] no changes to frontend / backend
---
pylabrobot/storage/backend.py | 48 +------------------------
pylabrobot/storage/incubator.py | 62 +++------------------------------
2 files changed, 5 insertions(+), 105 deletions(-)
diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py
index 07153909651..82af1917e04 100644
--- a/pylabrobot/storage/backend.py
+++ b/pylabrobot/storage/backend.py
@@ -3,7 +3,6 @@
from pylabrobot.machines.backend import MachineBackend
from pylabrobot.resources import Plate, PlateCarrier, PlateHolder
-from pylabrobot.resources.barcode import Barcode
class IncubatorBackend(MachineBackend, metaclass=ABCMeta):
@@ -46,53 +45,8 @@ async def get_temperature(self) -> float:
@abstractmethod
async def start_shaking(self, frequency: float):
"""Start shaking the incubator at the given frequency in Hz."""
+ pass
@abstractmethod
async def stop_shaking(self):
pass
-
- @abstractmethod
- async def get_target_temperature(self) -> float:
- """Get the set value temperature of the incubator in degrees Celsius."""
-
- @abstractmethod
- async def set_humidity(self, humidity: float):
- """Set operation humidity of the incubator as a fraction; e.g. 0.9 for 90% RH."""
-
- @abstractmethod
- async def get_humidity(self) -> float:
- """Get the current humidity of the incubator as a fraction; e.g. 0.9 for 90% RH."""
-
- @abstractmethod
- async def get_target_humidity(self) -> float:
- """Get the set value humidity of the incubator as a fraction; e.g. 0.9 for 90% RH."""
-
- @abstractmethod
- async def set_co2_level(self, co2_level: float):
- """Set operation CO2 level of the incubator as a fraction; e.g. 0.05 for 5%."""
-
- @abstractmethod
- async def get_co2_level(self) -> float:
- """Get the current CO2 level of the incubator as a fraction; e.g. 0.05 for 5%."""
-
- @abstractmethod
- async def get_target_co2_level(self) -> float:
- """Get the set value CO2 level of the incubator as a fraction; e.g. 0.05 for 5%."""
-
- @abstractmethod
- async def set_n2_level(self, n2_level: float):
- """Set operation N2 level of the incubator as a fraction; e.g. 0.9 for 90%."""
-
- @abstractmethod
- async def get_n2_level(self) -> float:
- """Get the current N2 level of the incubator as a fraction; e.g. 0.9 for 90%."""
-
- @abstractmethod
- async def get_target_n2_level(self) -> float:
- """Get the set value N2 level of the incubator as a fraction; e.g. 0.9 for 90%."""
-
- @abstractmethod
- 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"""
diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py
index a07de473b4a..6ed68482173 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, **backend_kwargs) -> Plate:
+ async def fetch_plate_to_loading_tray(self, plate_name: str) -> 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, **backend_kwargs)
+ await self.backend.fetch_plate_to_loading_tray(plate)
plate.unassign()
self.loading_tray.assign_child_resource(plate)
return plate
@@ -112,9 +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"]], **backend_kwargs
- ):
+ async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]):
"""Take a plate from the loading tray and put it in the incubator."""
plate = cast(Plate, self.loading_tray.resource)
@@ -130,7 +128,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}")
- await self.backend.take_in_plate(plate, site, **backend_kwargs)
+ await self.backend.take_in_plate(plate, site)
plate.unassign()
site.assign_child_resource(plate)
@@ -209,55 +207,3 @@ def deserialize(cls, data: dict, allow_marshal: bool = False):
category=data["category"],
model=data["model"],
)
-
- async def get_target_temperature(self) -> float:
- """Get the set value temperature of the incubator in degrees Celsius."""
- return await self.backend.get_target_temperature()
-
- async def set_humidity(self, humidity: float):
- """Set the humidity of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.set_humidity(humidity)
-
- async def get_humidity(self) -> float:
- """Get the humidity of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_humidity()
-
- async def get_target_humidity(self) -> float:
- """Get the set value humidity of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_target_humidity()
-
- async def set_co2_level(self, co2_level: float):
- """Set the CO2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.set_co2_level(co2_level)
-
- async def get_co2_level(self) -> float:
- """Get the CO2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_co2_level()
-
- async def get_target_co2_level(self) -> float:
- """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_target_co2_level()
-
- async def set_n2_level(self, n2_level: float):
- """Set the N2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.set_n2_level(n2_level)
-
- async def get_n2_level(self) -> float:
- """Get the N2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_n2_level()
-
- async def get_target_n2_level(self) -> float:
- """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0)."""
- return await self.backend.get_target_n2_level()
-
- async def move_position_to_position(
- self, plate_name: str, dest_site: PlateHolder, **backend_kwargs
- ) -> 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
- await self.backend.move_position_to_position(plate, dest_site, **backend_kwargs)
- plate.unassign()
- dest_site.assign_child_resource(plate)
- return plate
From f4c6abba0149b47ad5747227340f3f0066ecb340 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Tue, 3 Mar 2026 16:53:47 -0800
Subject: [PATCH 50/56] format
---
pylabrobot/barcode_scanners/backend.py | 1 -
pylabrobot/barcode_scanners/keyence/keyence_backend.py | 2 +-
pylabrobot/storage/__init__.py | 2 +-
3 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/barcode_scanners/backend.py
index ce7bbe2b7a1..4a8b75fb9ae 100644
--- a/pylabrobot/barcode_scanners/backend.py
+++ b/pylabrobot/barcode_scanners/backend.py
@@ -15,4 +15,3 @@ def __init__(self):
@abstractmethod
async def scan_barcode(self) -> Barcode:
"""Scan a barcode and return its value."""
- pass
diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
index c54c12f6171..1c544f6c2b3 100644
--- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -57,7 +57,7 @@ async def initialize(self):
await asyncio.sleep(self.poll_interval)
else:
raise BarcodeScannerError(
- "Failed to initialize Keyence barcode scanner: " "Timeout waiting for motor to turn on."
+ "Failed to initialize Keyence barcode scanner: Timeout waiting for motor to turn on."
)
async def send_command(self, command: str) -> str:
diff --git a/pylabrobot/storage/__init__.py b/pylabrobot/storage/__init__.py
index d12c23c4d93..84c204a9223 100644
--- a/pylabrobot/storage/__init__.py
+++ b/pylabrobot/storage/__init__.py
@@ -2,5 +2,5 @@
from .chatterbox import IncubatorChatterboxBackend
from .cytomat import CytomatBackend
from .incubator import Incubator
-from .liconic import LiconicBackend
from .inheco.scila import SCILABackend
+from .liconic import LiconicBackend
From b786c5dcbf8d2071876b8a2938ac79364e1873de Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Tue, 3 Mar 2026 16:56:04 -0800
Subject: [PATCH 51/56] add to docs index
---
.../01_material-handling/storage/storage.rst | 39 ++++++++++---------
1 file changed, 20 insertions(+), 19 deletions(-)
diff --git a/docs/user_guide/01_material-handling/storage/storage.rst b/docs/user_guide/01_material-handling/storage/storage.rst
index 4398951edef..698d153b308 100644
--- a/docs/user_guide/01_material-handling/storage/storage.rst
+++ b/docs/user_guide/01_material-handling/storage/storage.rst
@@ -7,8 +7,8 @@ A storage machine is defined as a **machine whose primary feature is**
Examples of this simplest form of a storage machine include:
-- `Agilent Labware MiniHub `_ – open storage of labware with rotation feature
-- `Lab Services PlateCarousel `_ – open storage of labware with rotation feature
+- `Agilent Labware MiniHub `_ - open storage of labware with rotation feature
+- `Lab Services PlateCarousel `_ - open storage of labware with rotation feature
However, this purposefully broad definition means most storage machines also include other features such as:
@@ -48,7 +48,7 @@ However, this purposefully broad definition means most storage machines also inc
The only time the term plate hotel or hotel is used in PyLabRobot is when
- referring to the name of a specific machine, such as the
+ referring to the name of a specific machine, such as the
TFS Cytomat™ 2 Hotel Automated Storage.
In this case, it is used as a noun to refer to a specific product.
@@ -73,21 +73,21 @@ Retrieval Pattern: Stacking (Sequential) vs. Random Access
* - **Stacking Access (Sequential)**
- **Random Access**
- * - Materials stored in a fixed order (e.g. vertical stack, rotating carousel).
+ * - Materials stored in a fixed order (e.g. vertical stack, rotating carousel).
Only the top/front-most item is accessible without mechanical movement.
- - Materials stored in individually addressable slots or shelves.
+ - Materials stored in individually addressable slots or shelves.
Any item can be accessed directly.
* - Slower access time for deeper items.
- Faster access to any item.
* - Simpler mechanics, smaller footprint.
- More flexible but mechanically complex.
- * - **Examples:**
+ * - **Examples:**
- - Agilent Labware MiniHub
+ - Agilent Labware MiniHub
- Lab Services PlateCarousel
- - **Examples:**
-
- - Thermo Cytomat 2 C450
+ - **Examples:**
+
+ - Thermo Cytomat 2 C450
- LiCONiC STX Series
Accessibility: Open vs. Closed Storage
@@ -99,19 +99,19 @@ Accessibility: Open vs. Closed Storage
* - **Open Storage**
- **Closed Storage**
- * - Materials are exposed without obstruction.
+ * - Materials are exposed without obstruction.
No barrier between the robot and the stored material.
- - Materials enclosed in a chamber.
+ - Materials enclosed in a chamber.
Access requires opening a door, drawer, or robotic port.
* - Simplifies integration and visual inspection.
- Enables environmental control (temperature, humidity, sterility).
* - No protection from contamination or temperature drift.
- Ideal for incubators, cold storage, and sterile handling.
- * - **Examples:**
- - Agilent Labware MiniHub
+ * - **Examples:**
+ - Agilent Labware MiniHub
- Manual stackers
- - **Examples:**
- - Thermo Cytomat 2
+ - **Examples:**
+ - Thermo Cytomat 2
- LiCONiC STX incubators
Combined Retrieval & Access Summary
@@ -120,16 +120,16 @@ Combined Retrieval & Access Summary
.. list-table::
:header-rows: 1
- * -
+ * -
- **Open Storage**
- **Closed Storage**
* - **Stacking Access (Sequential)**
- - Agilent Labware MiniHub
+ - Agilent Labware MiniHub
Lab Services PlateCarousel
- STX incubators with drawer-based shelves
* - **Random Access**
- Rare in open format (e.g. manual racks)
- - Thermo Cytomat 2 C450
+ - Thermo Cytomat 2 C450
LiCONiC STX Series
@@ -142,3 +142,4 @@ Combined Retrieval & Access Summary
cytomat
inheco/incubator_shaker
inheco/scila
+ liconic
From 7aa666c98f0b018ec9221989c2f06003addf677f Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Tue, 3 Mar 2026 17:29:13 -0800
Subject: [PATCH 52/56] Fix review bugs and add tests for untested backend
methods
- Fix MachineBackend.deserialize() KeyError (referenced popped key)
- Fix assertRaises misuse in test (msg= doesn't check exception text)
- Change error maps from pre-instantiated singletons to (class, msg)
tuples to avoid stale __traceback__ across raises
- Replace cast(Plate, ...) with isinstance check in Incubator.take_in_plate
- Add 21 tests for previously-untested methods: shaking, door control,
sensors, climate getters, swap station, initialize
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/machines/backend.py | 4 +-
pylabrobot/storage/incubator.py | 14 +-
pylabrobot/storage/liconic/errors.py | 516 +++++++++++-------
pylabrobot/storage/liconic/liconic_backend.py | 6 +-
.../storage/liconic/liconic_backend_tests.py | 155 +++++-
5 files changed, 478 insertions(+), 217 deletions(-)
diff --git a/pylabrobot/machines/backend.py b/pylabrobot/machines/backend.py
index 17c1ccc6743..1dab93ce246 100644
--- a/pylabrobot/machines/backend.py
+++ b/pylabrobot/machines/backend.py
@@ -30,9 +30,9 @@ def deserialize(cls, data: dict):
class_name = data.pop("type")
subclass = find_subclass(class_name, cls=cls)
if subclass is None:
- raise ValueError(f'Could not find subclass with name "{data["type"]}"')
+ raise ValueError(f'Could not find subclass with name "{class_name}"')
if inspect.isabstract(subclass):
- raise ValueError(f'Subclass with name "{data["type"]}" is abstract')
+ raise ValueError(f'Subclass with name "{class_name}" is abstract')
assert issubclass(subclass, cls)
return subclass(**data)
diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/storage/incubator.py
index 6ed68482173..4a4d6d5fe64 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, **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
- 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
@@ -112,11 +112,13 @@ 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"]], **backend_kwargs
+ ):
"""Take a plate from the loading tray and put it in the incubator."""
- plate = cast(Plate, self.loading_tray.resource)
- if plate is None:
+ plate = self.loading_tray.resource
+ if not isinstance(plate, Plate):
raise ResourceNotFoundError(f"No plate on the loading tray of incubator '{self.name}'")
if site == "random":
@@ -128,7 +130,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, **backend_kwargs)
plate.unassign()
site.assign_child_resource(plate)
diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/storage/liconic/errors.py
index 2945a9247cc..0025661ca97 100644
--- a/pylabrobot/storage/liconic/errors.py
+++ b/pylabrobot/storage/liconic/errors.py
@@ -1,4 +1,4 @@
-from typing import Dict
+from typing import Dict, Tuple, Type
from pylabrobot.storage.liconic.constants import ControllerError, HandlingError
@@ -27,24 +27,30 @@ 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"
+controller_error_map: Dict[ControllerError, Tuple[Type[Exception], str]] = {
+ 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.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.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.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.WRITE_PROTECTED_ERROR: (
+ LiconicControllerWriteProtectedError,
+ "Controller system error. Unauthorized Access",
),
- ControllerError.BASE_UNIT_ERROR: LiconicControllerBaseUnitError(
- "Controller system error. Unauthorized Access"
+ ControllerError.BASE_UNIT_ERROR: (
+ LiconicControllerBaseUnitError,
+ "Controller system error. Unauthorized Access",
),
}
@@ -81,285 +87,389 @@ class LiconicHandlerGeneralError(Exception):
pass
-handler_error_map: Dict[HandlingError, Exception] = {
- HandlingError.GENERAL_HANDLING_ERROR: LiconicHandlerGeneralError(
- "Handling action could not be performed in time"
+handler_error_map: Dict[HandlingError, Tuple[Type[Exception], str]] = {
+ 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_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.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.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.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.STACKER_SLOT_ERROR: (
+ LiconicHandlerGeneralError,
+ "Stacker slot cannot be reached",
),
- HandlingError.PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerGeneralError(
- "Export operation while plate is on transfer station"
+ HandlingError.REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerGeneralError,
+ "Undefined stacker level has been requested",
),
- HandlingError.LIFT_INITIALIZATION_ERROR: LiconicHandlerGeneralError(
- "Lift could not be initialized"
+ HandlingError.PLATE_TRANSFER_DETECTION_ERROR: (
+ LiconicHandlerGeneralError,
+ "Export operation while plate is on transfer station",
),
- HandlingError.PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.LIFT_INITIALIZATION_ERROR: (
+ LiconicHandlerGeneralError,
+ "Lift could not be initialized",
),
- HandlingError.NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerGeneralError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerGeneralError,
+ "Trying to load a plate, when a plate is already 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.NO_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerGeneralError,
+ "Trying to remove or place plate with no plate on the shovel",
),
- HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: LiconicHandlerImportPlateError(
- "Handler could not reach outer turn position at transfer level during Import Plate procedure."
+ HandlingError.NO_RECOVERY: (
+ LiconicHandlerGeneralError,
+ "Recovery was not possible",
),
- HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: LiconicHandlerImportPlateError(
- "Shovel could not reach outer position at transfer level during Import Plate procedure."
+ 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_LIFT_TRANSFER_ERROR: LiconicHandlerImportPlateError(
- "Lift did not reach upper pick position at 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_INNER_ERROR: LiconicHandlerImportPlateError(
- "Shovel could not reach inner 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_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerImportPlateError(
- "Handler could not reach inner turn 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_LIFT_STACKER_TRAVEL_ERROR: LiconicHandlerImportPlateError(
- "Lift could not reach desired stacker 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_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerImportPlateError(
- "Shovel could not reach front position on stacker access during Plate Import 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_PLACE_ERROR: LiconicHandlerImportPlateError(
- "Lift could not reach stacker place 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_INNER_ERROR: LiconicHandlerImportPlateError(
- "Shovel could not reach inner position at stacker plate placement 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_TRAVEL_BACK_ERROR: LiconicHandlerImportPlateError(
- "Lift could not reach zero level during Import Plate procedure."
+ HandlingError.IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR: (
+ LiconicHandlerImportPlateError,
+ "Lift could not reach stacker place level during Import Plate procedure.",
),
- HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: LiconicHandlerImportPlateError(
- "Lift could not be initialized after 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.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.IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: (
+ LiconicHandlerImportPlateError,
+ "Lift could not reach zero level during Import Plate procedure.",
),
- HandlingError.EXPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: LiconicHandlerExportPlateError(
- "Shovel could not reach front position on stacker access during Plate Export procedure."
+ HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: (
+ LiconicHandlerImportPlateError,
+ "Lift could not be initialized after Import Plate procedure.",
),
- HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: LiconicHandlerExportPlateError(
- "Lift could not reach stacker pick level during Export 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_INNER_ERROR: LiconicHandlerExportPlateError(
- "Shovel could not reach inner position at stacker plate pick 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_TRANSFER_POSITIONING_ERROR: LiconicHandlerExportPlateError(
- "Lift could not reach transfer level during Export Plate procedure."
+ HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: (
+ LiconicHandlerExportPlateError,
+ "Lift could not reach stacker pick 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_STACKER_INNER_ERROR: (
+ LiconicHandlerExportPlateError,
+ "Shovel could not reach inner position at stacker plate pick 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_POSITIONING_ERROR: (
+ LiconicHandlerExportPlateError,
+ "Lift could not reach 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_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_INNER_ERROR: LiconicHandlerExportPlateError(
- "Shovel could not reach inner 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_HANDLER_TRANSFER_TURN_IN_ERROR: LiconicHandlerExportPlateError(
- "Handler could not reach inner turn 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_LIFT_TRAVEL_BACK_ERROR: LiconicHandlerExportPlateError(
- "Lift could not reach Zero position 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_LIFT_INITIALIZING_ERROR: LiconicHandlerExportPlateError(
- "Lift could not be initialized after 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.PLATE_REMOVE_GENERAL_HANDLING_ERROR: LiconicHandlerPlateRemoveError(
- "Handling action could not be performed in time."
+ HandlingError.EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: (
+ LiconicHandlerExportPlateError,
+ "Lift could not reach Zero position during Export Plate procedure.",
),
- HandlingError.PLATE_REMOVE_GATE_OPEN_ERROR: LiconicHandlerPlateRemoveError(
- "Gate could not reach upper position or Gate did not reach upper position in time"
+ HandlingError.EXPORT_PLATE_LIFT_INITIALIZING_ERROR: (
+ LiconicHandlerExportPlateError,
+ "Lift could not be initialized after Export Plate procedure.",
),
- 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_HANDLING_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Handling action could not be performed 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_GATE_OPEN_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Gate could not reach upper position or Gate did not reach upper position in time",
),
- HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: LiconicHandlerPlateRemoveError(
- "Unauthorized user access in combination with manual rotation of carrousel"
+ 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_STACKER_SLOT_ERROR: LiconicHandlerPlateRemoveError(
- "Stacker slot cannot be reached"
+ HandlingError.PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Handler-Lift could not reach desired level position or does not move",
),
- HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateRemoveError(
- "Undefined stacker level has been requested"
+ HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Unauthorized user access in combination with manual rotation of carrousel",
),
- HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateRemoveError(
- "Export operation while plate is on transfer station"
+ HandlingError.PLATE_REMOVE_STACKER_SLOT_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Stacker slot cannot be reached",
),
- HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateRemoveError(
- "Lift could not be initialized"
+ HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Undefined stacker level has been requested",
),
- HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Export operation while plate is on transfer station",
),
- HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateRemoveError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: (
+ LiconicHandlerPlateRemoveError,
+ "Lift could not be initialized",
),
- HandlingError.PLATE_REMOVE_NO_RECOVERY: LiconicHandlerPlateRemoveError(
- "Recovery was not possible"
+ HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlateRemoveError,
+ "Trying to load a plate, when a plate is already on the shovel",
),
- HandlingError.BARCODE_READ_GENERAL_HANDLING_ERROR: LiconicHandlerBarcodeReadError(
- "Handling action could not be performed in time."
+ HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlateRemoveError,
+ "Trying to remove or place plate with no plate on the shovel",
),
- HandlingError.BARCODE_READ_GATE_OPEN_ERROR: LiconicHandlerBarcodeReadError(
- "Gate could not reach upper position or Gate did not reach upper position in time"
+ HandlingError.PLATE_REMOVE_NO_RECOVERY: (
+ LiconicHandlerPlateRemoveError,
+ "Recovery was not possible",
),
- 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_HANDLING_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Handling action could not be performed 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_GATE_OPEN_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Gate could not reach upper position or Gate did not reach upper position in time",
),
- HandlingError.BARCODE_READ_USER_ACCESS_ERROR: LiconicHandlerBarcodeReadError(
- "Unauthorized user access in combination with manual rotation of carrousel"
+ 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_STACKER_SLOT_ERROR: LiconicHandlerBarcodeReadError(
- "Stacker slot cannot be reached"
+ HandlingError.BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Handler-Lift could not reach desired level position or does not move",
),
- HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerBarcodeReadError(
- "Undefined stacker level has been requested"
+ HandlingError.BARCODE_READ_USER_ACCESS_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Unauthorized user access in combination with manual rotation of carrousel",
),
- HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerBarcodeReadError(
- "Export operation while plate is on transfer station"
+ HandlingError.BARCODE_READ_STACKER_SLOT_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Stacker slot cannot be reached",
),
- HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: LiconicHandlerBarcodeReadError(
- "Lift could not be initialized"
+ HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Undefined stacker level has been requested",
),
- HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Export operation while plate is on transfer station",
),
- HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerBarcodeReadError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: (
+ LiconicHandlerBarcodeReadError,
+ "Lift could not be initialized",
),
- HandlingError.BARCODE_READ_NO_RECOVERY: LiconicHandlerBarcodeReadError(
- "Recovery was not possible"
+ HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerBarcodeReadError,
+ "Trying to load a plate, when a plate is already on the shovel",
),
- HandlingError.PLATE_PLACE_GENERAL_HANDLING_ERROR: LiconicHandlerPlatePlaceError(
- "Handling action could not be performed in time."
+ HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerBarcodeReadError,
+ "Trying to remove or place plate with no plate on the shovel",
),
- HandlingError.PLATE_PLACE_GATE_OPEN_ERROR: LiconicHandlerPlatePlaceError(
- "Gate could not reach upper position or Gate did not reach upper position in time"
+ HandlingError.BARCODE_READ_NO_RECOVERY: (
+ LiconicHandlerBarcodeReadError,
+ "Recovery was not possible",
),
- 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_HANDLING_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Handling action could not be performed 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_GATE_OPEN_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Gate could not reach upper position or Gate did not reach upper position in time",
),
- HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: LiconicHandlerPlatePlaceError(
- "Unauthorized user access in combination with manual rotation of carrousel"
+ 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_STACKER_SLOT_ERROR: LiconicHandlerPlatePlaceError(
- "Stacker slot cannot be reached"
+ HandlingError.PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Handler-Lift could not reach desired level position or does not move",
),
- HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlatePlaceError(
- "Undefined stacker level has been requested"
+ HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Unauthorized user access in combination with manual rotation of carrousel",
),
- HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlatePlaceError(
- "Export operation while plate is on transfer station"
+ HandlingError.PLATE_PLACE_STACKER_SLOT_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Stacker slot cannot be reached",
),
- HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlatePlaceError(
- "Lift could not be initialized"
+ HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Undefined stacker level has been requested",
),
- HandlingError.PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Export operation while plate is on transfer station",
),
- HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlatePlaceError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: (
+ LiconicHandlerPlatePlaceError,
+ "Lift could not be initialized",
),
- 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_PLACE_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlatePlaceError,
+ "Trying to load a plate, when a plate is already on the shovel",
),
- HandlingError.PLATE_SET_GATE_OPEN_ERROR: LiconicHandlerPlateSetError(
- "Gate could not reach upper position or Gate did not reach upper position in time"
+ HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlatePlaceError,
+ "Trying to remove or place plate with no plate on the shovel",
),
- HandlingError.PLATE_SET_GATE_CLOSE_ERROR: LiconicHandlerPlateSetError(
- "Gate could not reach lower position or Gate did not reach lower position in time"
+ HandlingError.PLATE_PLACE_NO_RECOVERY: (
+ LiconicHandlerPlatePlaceError,
+ "Recovery was not possible",
),
- HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateSetError(
- "Handler-Lift could not reach desired level position or does not move"
+ HandlingError.PLATE_SET_GENERAL_HANDLING_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Handling action could not be performed in time.",
),
- HandlingError.PLATE_SET_USER_ACCESS_ERROR: LiconicHandlerPlateSetError(
- "Unauthorized user access in combination with manual rotation of carrousel"
+ 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_STACKER_SLOT_ERROR: LiconicHandlerPlateSetError(
- "Stacker slot cannot be reached"
+ 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_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateSetError(
- "Undefined stacker level has been requested"
+ HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Handler-Lift could not reach desired level position or does not move",
),
- HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateSetError(
- "Export operation while plate is on transfer station"
+ HandlingError.PLATE_SET_USER_ACCESS_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Unauthorized user access in combination with manual rotation of carrousel",
),
- HandlingError.PLATE_SET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateSetError(
- "Lift could not be initialized"
+ HandlingError.PLATE_SET_STACKER_SLOT_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Stacker slot cannot be reached",
),
- HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Undefined stacker level has been requested",
),
- HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateSetError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Export operation while plate is on transfer station",
),
- 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_SET_LIFT_INITIALIZATION_ERROR: (
+ LiconicHandlerPlateSetError,
+ "Lift could not be initialized",
),
- HandlingError.PLATE_GET_GATE_OPEN_ERROR: LiconicHandlerPlateGetError(
- "Gate could not reach upper position or Gate did not reach upper position in time"
+ HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlateSetError,
+ "Trying to load a plate, when a plate is already on the shovel",
),
- HandlingError.PLATE_GET_GATE_CLOSE_ERROR: LiconicHandlerPlateGetError(
- "Gate could not reach lower position or Gate did not reach lower position in time"
+ HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: (
+ LiconicHandlerPlateSetError,
+ "Trying to remove or place plate with no plate on the shovel",
),
- HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: LiconicHandlerPlateGetError(
- "Handler-Lift could not reach desired level position or does not move"
+ HandlingError.PLATE_SET_NO_RECOVERY: (
+ LiconicHandlerPlateSetError,
+ "Recovery was not possible",
),
- HandlingError.PLATE_GET_USER_ACCESS_ERROR: LiconicHandlerPlateGetError(
- "Unauthorized user access in combination with manual rotation of carrousel"
+ HandlingError.PLATE_GET_GENERAL_HANDLING_ERROR: (
+ LiconicHandlerPlateGetError,
+ "Handling action could not be performed in time.",
),
- HandlingError.PLATE_GET_STACKER_SLOT_ERROR: LiconicHandlerPlateGetError(
- "Stacker slot cannot be reached"
+ 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_REMOTE_ACCESS_LEVEL_ERROR: LiconicHandlerPlateGetError(
- "Undefined stacker level has been requested"
+ 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_PLATE_TRANSFER_DETECTION_ERROR: LiconicHandlerPlateGetError(
- "Export operation while plate is on transfer station"
+ HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: (
+ LiconicHandlerPlateGetError,
+ "Handler-Lift could not reach desired level position or does not move",
),
- HandlingError.PLATE_GET_LIFT_INITIALIZATION_ERROR: LiconicHandlerPlateGetError(
- "Lift could not be initialized"
+ HandlingError.PLATE_GET_USER_ACCESS_ERROR: (
+ LiconicHandlerPlateGetError,
+ "Unauthorized user access in combination with manual rotation of carrousel",
),
- HandlingError.PLATE_GET_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError(
- "Trying to load a plate, when a plate is already on the shovel"
+ HandlingError.PLATE_GET_STACKER_SLOT_ERROR: (
+ LiconicHandlerPlateGetError,
+ "Stacker slot cannot be reached",
),
- HandlingError.PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION: LiconicHandlerPlateGetError(
- "Trying to remove or place plate with no plate on the shovel"
+ HandlingError.PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR: (
+ LiconicHandlerPlateGetError,
+ "Undefined stacker level has been requested",
),
- HandlingError.PLATE_GET_NO_RECOVERY: LiconicHandlerPlateGetError(
- "Recovery was not possible during get plate"
+ 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 1f4f66b8210..1befab23c36 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -282,7 +282,8 @@ async def _send_command(self, command: str) -> str:
logger.error(f"Command {command} failed with {resp}")
for member in ControllerError:
if resp == member.value:
- raise controller_error_map[member]
+ cls, msg = controller_error_map[member]
+ raise cls(msg)
raise RuntimeError(f"Unknown error {resp} when sending command {command}")
return resp
@@ -316,7 +317,8 @@ async def _wait_ready(self, timeout: int = 60):
error = await self._send_command("RD DM200")
for member in HandlingError:
if error == member.value:
- raise handler_error_map[member]
+ cls, msg = handler_error_map[member]
+ raise cls(msg)
raise RuntimeError(f"Liconic Handler in unknown error state with memory showing {error}")
raise TimeoutError(f"Incubator did not become ready within {timeout} seconds")
diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py
index 4e130bdb8c9..148d8c34b01 100644
--- a/pylabrobot/storage/liconic/liconic_backend_tests.py
+++ b/pylabrobot/storage/liconic/liconic_backend_tests.py
@@ -1,7 +1,7 @@
import unittest
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock
-from pylabrobot.resources import Plate, PlateHolder
+from pylabrobot.resources import PlateHolder
from pylabrobot.resources.carrier import PlateCarrier
from pylabrobot.storage.liconic.constants import LiconicType
from pylabrobot.storage.liconic.liconic_backend import LICONIC_SITE_HEIGHT_TO_STEPS, LiconicBackend
@@ -9,7 +9,6 @@
liconic_rack_5mm_42,
liconic_rack_17mm_22,
liconic_rack_44mm_10,
- liconic_rack_104mm_4,
)
@@ -216,6 +215,153 @@ async def test_nc_model_rejects_climate(self):
await backend.set_humidity(0.5)
+class TestShaking(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._send_command = AsyncMock(return_value="OK")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_stop_shaking(self):
+ await self.backend.stop_shaking()
+ self.backend._send_command.assert_any_call("RS 1913")
+ self.backend._wait_ready.assert_awaited()
+
+ async def test_get_shaker_speed(self):
+ self.backend._send_command = AsyncMock(return_value="250")
+ speed = await self.backend.get_shaker_speed()
+ self.assertAlmostEqual(speed, 25.0)
+
+ async def test_shaker_status_not_implemented(self):
+ with self.assertRaises(NotImplementedError):
+ await self.backend.shaker_status()
+
+
+class TestDoorControl(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._send_command = AsyncMock(return_value="OK")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_open_door(self):
+ await self.backend.open_door()
+ self.backend._send_command.assert_any_call("ST 1901")
+ self.backend._wait_ready.assert_awaited()
+
+ async def test_close_door(self):
+ await self.backend.close_door()
+ self.backend._send_command.assert_any_call("ST 1902")
+ self.backend._wait_ready.assert_awaited()
+
+
+class TestSensors(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_check_shovel_sensor_true(self):
+ self.backend._send_command = AsyncMock(side_effect=["OK", "1"])
+ result = await self.backend.check_shovel_sensor()
+ self.assertTrue(result)
+
+ async def test_check_shovel_sensor_false(self):
+ self.backend._send_command = AsyncMock(side_effect=["OK", "0"])
+ result = await self.backend.check_shovel_sensor()
+ self.assertFalse(result)
+
+ async def test_check_shovel_sensor_unexpected(self):
+ self.backend._send_command = AsyncMock(side_effect=["OK", "X"])
+ with self.assertRaises(RuntimeError):
+ await self.backend.check_shovel_sensor()
+
+ async def test_check_transfer_sensor_true(self):
+ self.backend._send_command = AsyncMock(return_value="1")
+ result = await self.backend.check_transfer_sensor()
+ self.assertTrue(result)
+
+ async def test_check_transfer_sensor_false(self):
+ self.backend._send_command = AsyncMock(return_value="0")
+ result = await self.backend.check_transfer_sensor()
+ self.assertFalse(result)
+
+ async def test_check_second_transfer_sensor_true(self):
+ self.backend._send_command = AsyncMock(return_value="1")
+ result = await self.backend.check_second_transfer_sensor()
+ self.assertTrue(result)
+
+ async def test_check_second_transfer_sensor_false(self):
+ self.backend._send_command = AsyncMock(return_value="0")
+ result = await self.backend.check_second_transfer_sensor()
+ self.assertFalse(result)
+
+
+class TestClimateGetters(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_get_target_temperature(self):
+ self.backend._send_command = AsyncMock(return_value="375")
+ result = await self.backend.get_target_temperature()
+ self.assertAlmostEqual(result, 37.5)
+
+ async def test_get_target_humidity(self):
+ self.backend._send_command = AsyncMock(return_value="900")
+ result = await self.backend.get_target_humidity()
+ self.assertAlmostEqual(result, 0.9)
+
+ async def test_get_target_co2(self):
+ self.backend._send_command = AsyncMock(return_value="500")
+ result = await self.backend.get_target_co2_level()
+ self.assertAlmostEqual(result, 0.05)
+
+ async def test_get_n2_level(self):
+ self.backend._send_command = AsyncMock(return_value="9000")
+ result = await self.backend.get_n2_level()
+ self.assertAlmostEqual(result, 0.9)
+
+ async def test_get_target_n2(self):
+ self.backend._send_command = AsyncMock(return_value="9000")
+ result = await self.backend.get_target_n2_level()
+ self.assertAlmostEqual(result, 0.9)
+
+ async def test_nc_model_rejects_humidity(self):
+ backend = LiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
+ with self.assertRaises(NotImplementedError):
+ await backend.get_humidity()
+ with self.assertRaises(NotImplementedError):
+ await backend.get_target_humidity()
+ with self.assertRaises(NotImplementedError):
+ await backend.get_target_temperature()
+
+
+class TestSwapStation(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+
+ async def test_turn_swap_station_home_when_swapped(self):
+ self.backend._send_command = AsyncMock(return_value="1")
+ await self.backend.turn_swap_station(home=True)
+ self.backend._send_command.assert_any_call("RS 1912")
+
+ async def test_turn_swap_station_swap_when_home(self):
+ self.backend._send_command = AsyncMock(return_value="0")
+ await self.backend.turn_swap_station(home=False)
+ self.backend._send_command.assert_any_call("ST 1912")
+
+
+class TestInitialize(unittest.IsolatedAsyncioTestCase):
+ def setUp(self):
+ self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend._send_command = AsyncMock(return_value="OK")
+ self.backend._wait_ready = AsyncMock()
+
+ async def test_initialize(self):
+ await self.backend.initialize()
+ self.backend._send_command.assert_any_call("ST 1900")
+ self.backend._send_command.assert_any_call("ST 1801")
+ self.backend._wait_ready.assert_awaited()
+
+
class TestSerialization(unittest.TestCase):
def test_serialize_roundtrip(self):
backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/ttyUSB0")
@@ -251,8 +397,9 @@ async def test_send_command_raises_on_controller_error(self):
async def test_send_command_raises_on_unknown_error(self):
self.backend.io.read = AsyncMock(return_value=b"E9")
- with self.assertRaises(RuntimeError, msg="Unknown error"):
+ with self.assertRaises(RuntimeError) as ctx:
await self.backend._send_command("ST 1801")
+ self.assertIn("Unknown error", str(ctx.exception))
if __name__ == "__main__":
From df75f0f446ccb20cac2fda1db42d71326c2a51d7 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Tue, 3 Mar 2026 17:30:00 -0800
Subject: [PATCH 53/56] tiny doc format etc.
---
.../storage/liconic.ipynb | 77 ++++++++++++-------
.../keyence/keyence_backend.py | 4 +-
pylabrobot/storage/backend.py | 4 +-
3 files changed, 52 insertions(+), 33 deletions(-)
diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb
index ed896377ec6..3fcae3fe366 100644
--- a/docs/user_guide/01_material-handling/storage/liconic.ipynb
+++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb
@@ -5,7 +5,7 @@
"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",
@@ -27,33 +27,35 @@
"metadata": {},
"outputs": [],
"source": [
- "\n",
+ "from pylabrobot.barcode_scanners import BarcodeScanner, KeyenceBarcodeScannerBackend\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",
+ "barcode_scanner_backend = KeyenceBarcodeScannerBackend(port=\"COM4\")\n",
+ "barcode_scanner = BarcodeScanner(backend=barcode_scanner_backend)\n",
+ "\n",
+ "liconic_backend = LiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_scanner=barcode_scanner)\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_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",
- " liconic_rack_17mm_22(\"cassette_7\"),\n",
- " liconic_rack_17mm_22(\"cassette_8\"),\n",
- " liconic_rack_17mm_22(\"cassette_9\")\n",
- " ]\n",
+ " liconic_rack_44mm_10(\"cassette_0\"),\n",
+ " liconic_rack_44mm_10(\"cassette_1\"),\n",
+ " liconic_rack_44mm_10(\"cassette_2\"),\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",
+ " 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",
+ " backend=liconic_backend,\n",
" name=\"My Incubator\",\n",
- " size_x=100,\n",
- " size_y=100,\n",
- " size_z=100,\n",
+ " size_x=100, size_y=100, size_z=100, # stubs for now...\n",
" racks=rack,\n",
" loading_tray_location=Coordinate(x=0, y=0, z=0),\n",
")\n",
@@ -70,7 +72,7 @@
}
},
"source": [
- "Setup\n",
+ "## 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",
@@ -94,8 +96,9 @@
"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"
+ "## Usage\n",
+ "\n",
+ "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."
]
},
{
@@ -131,7 +134,7 @@
"metadata": {},
"outputs": [],
"source": [
- "await incubator.fetch_plate_to_loading_tray(plate=\"TEST\")\n",
+ "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\")\n",
"retrieved = incubator.loading_tray.resource"
]
},
@@ -157,12 +160,28 @@
"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",
+ "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\", 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"
+ "await incubator.take_in_plate(position, read_barcode=True)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d137d333",
+ "metadata": {},
+ "source": [
+ "move plate from one internal position to another internal position"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1aa68983",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await liconic_backend.move_position_to_position(plate=new_plate, dest_site=position, read_barcode=True)\n",
+ "# will set new_plate.barcode to the barcode read from the plate at position and move it to position."
]
},
{
@@ -182,7 +201,7 @@
"metadata": {},
"outputs": [],
"source": [
- "temperature = await incubator.get_temperature() # returns temperature as float in Celsius to the 10th place\n",
+ "temperature = await liconic_backend.get_temperature() # returns temperature as float in Celsius to the 10th place\n",
"print(str(temperature))"
]
},
@@ -219,7 +238,7 @@
"metadata": {},
"outputs": [],
"source": [
- "set_temperature = await incubator.get_target_temperature() # will return a float for the set temperature in degrees Celsius"
+ "set_temperature = await liconic_backend.get_target_temperature() # will return a float for the set temperature in degrees Celsius"
]
},
{
diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
index 1c544f6c2b3..8f18c191bac 100644
--- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -22,14 +22,14 @@ class KeyenceBarcodeScannerBackend(BarcodeScannerBackend):
def __init__(
self,
- serial_port: str,
+ 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,
+ port=port,
baudrate=self.default_baudrate,
bytesize=serial.SEVENBITS,
parity=serial.PARITY_EVEN,
diff --git a/pylabrobot/storage/backend.py b/pylabrobot/storage/backend.py
index 82af1917e04..82fb094dc62 100644
--- a/pylabrobot/storage/backend.py
+++ b/pylabrobot/storage/backend.py
@@ -27,11 +27,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, **backend_kwargs):
pass
@abstractmethod
- async def take_in_plate(self, plate: Plate, site: PlateHolder):
+ async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs):
pass
@abstractmethod
From 9b2067561259e33ffbe5bd9389b9393a595214bd Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Mon, 9 Mar 2026 11:01:59 -0700
Subject: [PATCH 54/56] Use 0-indexed site names with Cytomat format in Liconic
racks
Co-Authored-By: Claude Opus 4.6
---
pylabrobot/storage/liconic/racks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py
index 4dfbf1f7aa2..1f943868deb 100644
--- a/pylabrobot/storage/liconic/racks.py
+++ b/pylabrobot/storage/liconic/racks.py
@@ -21,7 +21,7 @@ def _liconic_rack(
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}",
+ name=f"{name}-{i}",
pedestal_size_z=0,
).at(
Coordinate(
From 8b704a81e92cfaba25bd8290e4d49737a94be320 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Mon, 9 Mar 2026 11:53:30 -0700
Subject: [PATCH 55/56] Fix CI: add human_readable_device_name to Serial calls,
fix override signatures, lint
Co-Authored-By: Claude Opus 4.6
---
.../barcode_scanners/keyence/keyence_backend.py | 1 +
pylabrobot/storage/chatterbox.py | 4 ++--
pylabrobot/storage/cytomat/cytomat.py | 4 ++--
pylabrobot/storage/cytomat/heraeus_cytomat_backend.py | 4 ++--
pylabrobot/storage/liconic/constants.py | 2 +-
pylabrobot/storage/liconic/liconic_backend.py | 11 ++++++++---
pylabrobot/storage/liconic/liconic_backend_tests.py | 2 ++
7 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
index 8f18c191bac..8377e1142b1 100644
--- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -29,6 +29,7 @@ def __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(
+ human_readable_device_name="Keyence Barcode Scanner",
port=port,
baudrate=self.default_baudrate,
bytesize=serial.SEVENBITS,
diff --git a/pylabrobot/storage/chatterbox.py b/pylabrobot/storage/chatterbox.py
index 09da0e929aa..89115098f00 100644
--- a/pylabrobot/storage/chatterbox.py
+++ b/pylabrobot/storage/chatterbox.py
@@ -19,10 +19,10 @@ async def open_door(self):
async def close_door(self):
print("Closing door")
- async def fetch_plate_to_loading_tray(self, plate: Plate):
+ async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs):
print(f"Fetching plate {plate} to loading tray")
- async def take_in_plate(self, plate: Plate, site: PlateHolder):
+ async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs):
print(f"Taking in plate {plate} at site {site}")
async def set_temperature(self, temperature: float):
diff --git a/pylabrobot/storage/cytomat/cytomat.py b/pylabrobot/storage/cytomat/cytomat.py
index 35792b2ead0..a544d8a6515 100644
--- a/pylabrobot/storage/cytomat/cytomat.py
+++ b/pylabrobot/storage/cytomat/cytomat.py
@@ -392,12 +392,12 @@ async def get_o2(self) -> CytomatIncupationResponse:
async def get_temperature(self) -> float:
return (await self.get_incubation_query("it")).actual_value
- async def fetch_plate_to_loading_tray(self, plate: Plate):
+ async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs):
site = plate.parent
assert isinstance(site, PlateHolder)
await self.action_storage_to_transfer(site)
- async def take_in_plate(self, plate: Plate, site: PlateHolder):
+ async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs):
await self.action_transfer_to_storage(site)
async def set_temperature(self, *args, **kwargs):
diff --git a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py
index e553daf0066..bc332679650 100644
--- a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py
+++ b/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py
@@ -109,7 +109,7 @@ async def close_door(self):
await self._send_command("ST 1902")
await self._wait_ready()
- async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder):
+ async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs):
"""Fetch a plate from storage onto the transfer station, with gate open/close."""
site = plate.parent
assert isinstance(site, PlateHolder), "Plate not in storage"
@@ -120,7 +120,7 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, site=PlateHolder):
await self._wait_ready()
await self._send_command("ST 1903") # terminate access
- async def take_in_plate(self, plate: Plate, site: PlateHolder):
+ async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs):
"""Place a plate from the transfer station into storage at the given site."""
m, n = self._site_to_m_n(site)
await self._send_command(f"WR DM0 {m}") # carousel pos
diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py
index 865b589a04d..b9ae563a061 100644
--- a/pylabrobot/storage/liconic/constants.py
+++ b/pylabrobot/storage/liconic/constants.py
@@ -1,4 +1,4 @@
-from enum import Enum, IntEnum
+from enum import Enum
class LiconicType(Enum):
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index 1befab23c36..a2ae97c6abf 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -67,6 +67,7 @@ def __init__(
self._racks: List[PlateCarrier] = []
self.io = Serial(
+ human_readable_device_name=f"Liconic {model.value}",
port=port,
baudrate=self.default_baud,
bytesize=serial.EIGHTBITS,
@@ -139,7 +140,7 @@ 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"):
+ if rack.model is None or 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:
@@ -174,7 +175,9 @@ async def close_door(self):
await self._send_command("ST 1902")
await self._wait_ready()
- async def fetch_plate_to_loading_tray(self, plate: Plate, read_barcode: bool = False):
+ async def fetch_plate_to_loading_tray(
+ self, plate: Plate, read_barcode: bool = False, **backend_kwargs
+ ):
"""Fetch a plate from the incubator to the loading tray."""
site = plate.parent
assert isinstance(site, PlateHolder), "Plate not in storage"
@@ -194,7 +197,9 @@ async def fetch_plate_to_loading_tray(self, plate: Plate, read_barcode: bool = F
await self._wait_ready()
await self._send_command("ST 1903") # terminate access
- async def take_in_plate(self, plate: Plate, site: PlateHolder, read_barcode: bool = False):
+ async def take_in_plate(
+ self, plate: Plate, site: PlateHolder, read_barcode: bool = False, **backend_kwargs
+ ):
"""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)
diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py
index 148d8c34b01..69644522815 100644
--- a/pylabrobot/storage/liconic/liconic_backend_tests.py
+++ b/pylabrobot/storage/liconic/liconic_backend_tests.py
@@ -1,3 +1,5 @@
+# mypy: disable-error-code="attr-defined,method-assign"
+
import unittest
from unittest.mock import AsyncMock
From ba6b97571efec7174cfb07e29539071498ddde91 Mon Sep 17 00:00:00 2001
From: Rick Wierenga
Date: Mon, 9 Mar 2026 12:16:00 -0700
Subject: [PATCH 56/56] ExperimentalLiconicBackend
---
.../storage/liconic.ipynb | 4 +--
pylabrobot/storage/__init__.py | 2 +-
pylabrobot/storage/liconic/__init__.py | 2 +-
pylabrobot/storage/liconic/liconic_backend.py | 2 +-
.../storage/liconic/liconic_backend_tests.py | 35 ++++++++++---------
5 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb
index 3fcae3fe366..051b0cb40a5 100644
--- a/docs/user_guide/01_material-handling/storage/liconic.ipynb
+++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb
@@ -29,7 +29,7 @@
"source": [
"from pylabrobot.barcode_scanners import BarcodeScanner, KeyenceBarcodeScannerBackend\n",
"from pylabrobot.resources.coordinate import Coordinate\n",
- "from pylabrobot.storage import LiconicBackend\n",
+ "from pylabrobot.storage import ExperimentalLiconicBackend\n",
"from pylabrobot.storage.incubator import Incubator\n",
"from pylabrobot.storage.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\n",
"\n",
@@ -37,7 +37,7 @@
"barcode_scanner_backend = KeyenceBarcodeScannerBackend(port=\"COM4\")\n",
"barcode_scanner = BarcodeScanner(backend=barcode_scanner_backend)\n",
"\n",
- "liconic_backend = LiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_scanner=barcode_scanner)\n",
+ "liconic_backend = ExperimentalLiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_scanner=barcode_scanner)\n",
"\n",
"rack = [\n",
" liconic_rack_44mm_10(\"cassette_0\"),\n",
diff --git a/pylabrobot/storage/__init__.py b/pylabrobot/storage/__init__.py
index 84c204a9223..3ccfc9cd4de 100644
--- a/pylabrobot/storage/__init__.py
+++ b/pylabrobot/storage/__init__.py
@@ -3,4 +3,4 @@
from .cytomat import CytomatBackend
from .incubator import Incubator
from .inheco.scila import SCILABackend
-from .liconic import LiconicBackend
+from .liconic import ExperimentalLiconicBackend
diff --git a/pylabrobot/storage/liconic/__init__.py b/pylabrobot/storage/liconic/__init__.py
index 99b93f73f0c..1eac4641b9e 100644
--- a/pylabrobot/storage/liconic/__init__.py
+++ b/pylabrobot/storage/liconic/__init__.py
@@ -1 +1 @@
-from .liconic_backend import LiconicBackend
+from .liconic_backend import ExperimentalLiconicBackend
diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/storage/liconic/liconic_backend.py
index a2ae97c6abf..9f6658a61be 100644
--- a/pylabrobot/storage/liconic/liconic_backend.py
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -35,7 +35,7 @@
}
-class LiconicBackend(IncubatorBackend):
+class ExperimentalLiconicBackend(IncubatorBackend):
"""Backend for Liconic incubators.
Optionally accepts a BarcodeScanner instance for internal barcode reading.
diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py
index 69644522815..1cbdeea61fa 100644
--- a/pylabrobot/storage/liconic/liconic_backend_tests.py
+++ b/pylabrobot/storage/liconic/liconic_backend_tests.py
@@ -6,7 +6,10 @@
from pylabrobot.resources import PlateHolder
from pylabrobot.resources.carrier import PlateCarrier
from pylabrobot.storage.liconic.constants import LiconicType
-from pylabrobot.storage.liconic.liconic_backend import LICONIC_SITE_HEIGHT_TO_STEPS, LiconicBackend
+from pylabrobot.storage.liconic.liconic_backend import (
+ LICONIC_SITE_HEIGHT_TO_STEPS,
+ ExperimentalLiconicBackend,
+)
from pylabrobot.storage.liconic.racks import (
liconic_rack_5mm_42,
liconic_rack_17mm_22,
@@ -74,7 +77,7 @@ def test_rack_custom_total_height(self):
class TestCarrierToStepsPos(unittest.TestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
def test_parses_model_name(self):
rack = liconic_rack_17mm_22("test_rack")
@@ -118,7 +121,7 @@ def test_unknown_model_raises(self):
class TestSiteToMN(unittest.TestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
def test_first_rack_first_site(self):
rack = liconic_rack_17mm_22("rack1")
@@ -147,7 +150,7 @@ class TestValueConversions(unittest.IsolatedAsyncioTestCase):
"""Test the PLC register value conversions without actual serial IO."""
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._send_command = AsyncMock(return_value="OK")
self.backend._wait_ready = AsyncMock()
@@ -208,7 +211,7 @@ async def test_start_shaking_range_high(self):
await self.backend.start_shaking(51.0)
async def test_nc_model_rejects_climate(self):
- backend = LiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
+ backend = ExperimentalLiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
with self.assertRaises(NotImplementedError):
await backend.set_temperature(37.0)
with self.assertRaises(NotImplementedError):
@@ -219,7 +222,7 @@ async def test_nc_model_rejects_climate(self):
class TestShaking(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._send_command = AsyncMock(return_value="OK")
self.backend._wait_ready = AsyncMock()
@@ -240,7 +243,7 @@ async def test_shaker_status_not_implemented(self):
class TestDoorControl(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._send_command = AsyncMock(return_value="OK")
self.backend._wait_ready = AsyncMock()
@@ -257,7 +260,7 @@ async def test_close_door(self):
class TestSensors(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._wait_ready = AsyncMock()
async def test_check_shovel_sensor_true(self):
@@ -298,7 +301,7 @@ async def test_check_second_transfer_sensor_false(self):
class TestClimateGetters(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._wait_ready = AsyncMock()
async def test_get_target_temperature(self):
@@ -327,7 +330,7 @@ async def test_get_target_n2(self):
self.assertAlmostEqual(result, 0.9)
async def test_nc_model_rejects_humidity(self):
- backend = LiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
+ backend = ExperimentalLiconicBackend(model=LiconicType.STX44_NC, port="/dev/null")
with self.assertRaises(NotImplementedError):
await backend.get_humidity()
with self.assertRaises(NotImplementedError):
@@ -338,7 +341,7 @@ async def test_nc_model_rejects_humidity(self):
class TestSwapStation(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
async def test_turn_swap_station_home_when_swapped(self):
self.backend._send_command = AsyncMock(return_value="1")
@@ -353,7 +356,7 @@ async def test_turn_swap_station_swap_when_home(self):
class TestInitialize(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend._send_command = AsyncMock(return_value="OK")
self.backend._wait_ready = AsyncMock()
@@ -366,23 +369,23 @@ async def test_initialize(self):
class TestSerialization(unittest.TestCase):
def test_serialize_roundtrip(self):
- backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/ttyUSB0")
+ backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/ttyUSB0")
data = backend.serialize()
self.assertEqual(data["port"], "/dev/ttyUSB0")
self.assertEqual(data["model"], "STX44_IC")
- restored = LiconicBackend.deserialize(data)
+ restored = ExperimentalLiconicBackend.deserialize(data)
self.assertEqual(restored.io.port, "/dev/ttyUSB0")
self.assertEqual(restored.model, LiconicType.STX44_IC)
def test_deserialize_string_model(self):
- restored = LiconicBackend.deserialize({"port": "/dev/ttyUSB0", "model": "STX44_IC"})
+ restored = ExperimentalLiconicBackend.deserialize({"port": "/dev/ttyUSB0", "model": "STX44_IC"})
self.assertEqual(restored.model, LiconicType.STX44_IC)
class TestErrorHandling(unittest.IsolatedAsyncioTestCase):
def setUp(self):
- self.backend = LiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
+ self.backend = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/null")
self.backend.io = AsyncMock()
async def test_send_command_raises_on_empty_response(self):