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..051b0cb40a5
--- /dev/null
+++ b/docs/user_guide/01_material-handling/storage/liconic.ipynb
@@ -0,0 +1,260 @@
+{
+ "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 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",
+ " - Configure racks\n",
+ " - Move plates in and out\n",
+ " - Set and monitor temperature and humidity values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "fcd75e15",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pylabrobot.barcode_scanners import BarcodeScanner, KeyenceBarcodeScannerBackend\n",
+ "from pylabrobot.resources.coordinate import Coordinate\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",
+ "\n",
+ "barcode_scanner_backend = KeyenceBarcodeScannerBackend(port=\"COM4\")\n",
+ "barcode_scanner = BarcodeScanner(backend=barcode_scanner_backend)\n",
+ "\n",
+ "liconic_backend = ExperimentalLiconicBackend(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",
+ "\n",
+ "incubator = Incubator(\n",
+ " backend=liconic_backend,\n",
+ " name=\"My Incubator\",\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",
+ "\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": [
+ "## 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."
+ ]
+ },
+ {
+ "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_name=\"TEST\")\n",
+ "retrieved = incubator.loading_tray.resource"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "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. 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"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c8730560",
+ "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"
+ ]
+ },
+ {
+ "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."
+ ]
+ },
+ {
+ "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 liconic_backend.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 liconic_backend.get_target_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": {
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
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
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
diff --git a/pylabrobot/barcode_scanners/__init__.py b/pylabrobot/barcode_scanners/__init__.py
new file mode 100644
index 00000000000..befd981f9a9
--- /dev/null
+++ b/pylabrobot/barcode_scanners/__init__.py
@@ -0,0 +1,3 @@
+from .backend import BarcodeScannerBackend, BarcodeScannerError
+from .barcode_scanner import BarcodeScanner
+from .keyence import KeyenceBarcodeScannerBackend
diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/barcode_scanners/backend.py
new file mode 100644
index 00000000000..4a8b75fb9ae
--- /dev/null
+++ b/pylabrobot/barcode_scanners/backend.py
@@ -0,0 +1,17 @@
+from abc import ABCMeta, abstractmethod
+
+from pylabrobot.machines.backend import MachineBackend
+from pylabrobot.resources.barcode import Barcode
+
+
+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) -> Barcode:
+ """Scan a barcode and return its value."""
diff --git a/pylabrobot/barcode_scanners/barcode_scanner.py b/pylabrobot/barcode_scanners/barcode_scanner.py
new file mode 100644
index 00000000000..821e5789ae2
--- /dev/null
+++ b/pylabrobot/barcode_scanners/barcode_scanner.py
@@ -0,0 +1,15 @@
+from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend
+from pylabrobot.machines.machine import Machine
+from pylabrobot.resources.barcode import Barcode
+
+
+class BarcodeScanner(Machine):
+ """Frontend for barcode scanners."""
+
+ def __init__(self, backend: BarcodeScannerBackend):
+ super().__init__(backend=backend)
+ self.backend: BarcodeScannerBackend = backend
+
+ 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/__init__.py b/pylabrobot/barcode_scanners/keyence/__init__.py
new file mode 100644
index 00000000000..db64521ca5c
--- /dev/null
+++ b/pylabrobot/barcode_scanners/keyence/__init__.py
@@ -0,0 +1 @@
+from .keyence_backend import KeyenceBarcodeScannerBackend
diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
new file mode 100644
index 00000000000..8377e1142b1
--- /dev/null
+++ b/pylabrobot/barcode_scanners/keyence/keyence_backend.py
@@ -0,0 +1,81 @@
+import asyncio
+import logging
+import time
+
+import serial
+
+from pylabrobot.barcode_scanners.backend import (
+ BarcodeScannerBackend,
+ BarcodeScannerError,
+)
+from pylabrobot.io.serial import Serial
+from pylabrobot.resources.barcode import Barcode
+
+logger = logging.getLogger(__name__)
+
+
+class KeyenceBarcodeScannerBackend(BarcodeScannerBackend):
+ default_baudrate = 9600
+ serial_messaging_encoding = "ascii"
+ init_timeout = 1.0 # seconds
+ poll_interval = 0.2 # seconds
+
+ def __init__(
+ self,
+ 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(
+ human_readable_device_name="Keyence Barcode Scanner",
+ port=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()
+
+ async def initialize(self):
+ """Initialize the Keyence barcode scanner."""
+
+ deadline = time.time() + self.init_timeout
+ while time.time() < deadline:
+ response = await self.send_command("RMOTOR")
+ if response.strip() == "MOTORON":
+ logger.info("Barcode scanner motor is ON.")
+ 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."
+ )
+
+ 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.read()
+ return response.decode(self.serial_messaging_encoding).strip()
+
+ async def stop(self):
+ await self.io.stop()
+
+ 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/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/__init__.py b/pylabrobot/storage/__init__.py
index 5de24b38012..3ccfc9cd4de 100644
--- a/pylabrobot/storage/__init__.py
+++ b/pylabrobot/storage/__init__.py
@@ -3,3 +3,4 @@
from .cytomat import CytomatBackend
from .incubator import Incubator
from .inheco.scila import SCILABackend
+from .liconic import ExperimentalLiconicBackend
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
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/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/__init__.py b/pylabrobot/storage/liconic/__init__.py
new file mode 100644
index 00000000000..1eac4641b9e
--- /dev/null
+++ b/pylabrobot/storage/liconic/__init__.py
@@ -0,0 +1 @@
+from .liconic_backend import ExperimentalLiconicBackend
diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/storage/liconic/constants.py
new file mode 100644
index 00000000000..b9ae563a061
--- /dev/null
+++ b/pylabrobot/storage/liconic/constants.py
@@ -0,0 +1,178 @@
+from enum import Enum
+
+
+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 = "STX280_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"
+ 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 = "00601"
+ 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..0025661ca97
--- /dev/null
+++ b/pylabrobot/storage/liconic/errors.py
@@ -0,0 +1,475 @@
+from typing import Dict, Tuple, Type
+
+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, 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.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, 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_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
new file mode 100644
index 00000000000..9f6658a61be
--- /dev/null
+++ b/pylabrobot/storage/liconic/liconic_backend.py
@@ -0,0 +1,579 @@
+import asyncio
+import logging
+import re
+import time
+import warnings
+from typing import List, Optional, Tuple, Union
+
+import serial
+
+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
+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
+}
+
+
+class ExperimentalLiconicBackend(IncubatorBackend):
+ """Backend for Liconic incubators.
+
+ Optionally accepts a BarcodeScanner instance for internal barcode reading.
+ """
+
+ default_baud = 9600
+ serial_message_encoding = "ascii"
+ init_timeout = 1.0
+ start_timeout = 15.0
+ poll_interval = 0.2
+
+ def __init__(
+ self,
+ model: Union[LiconicType, str],
+ port: str,
+ barcode_scanner: Optional[BarcodeScanner] = None,
+ ):
+ super().__init__()
+
+ self.barcode_scanner = barcode_scanner
+
+ 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 = Serial(
+ human_readable_device_name=f"Liconic {model.value}",
+ 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.co2_installed: Optional[bool] = None
+ self.n2_installed: Optional[bool] = None
+
+ # Function to setup serial connection with Liconic PLC
+ 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.
+ 3. Handshake: CR → wait for CC