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 `_ – 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 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 + 4. Activate handling: ST 1801 → expect OK + 5. Poll ready-flag: RD 1915 → wait for "1" + """ + try: + await self.io.setup() + except serial.SerialException as e: + raise RuntimeError(f"Could not open {self.io.port}: {e}") from e + + await self.io.send_break(duration=0.2) # >100 ms required + await asyncio.sleep(0.15) + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + await self.io.write(b"CR\r") + deadline = time.time() + self.init_timeout + while time.time() < deadline: + resp = await self.io.readline() # reads through LF + if resp.strip() == b"CC": + break + else: + await self.io.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.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.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]: + 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, 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 + + # 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 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: + site_height = int(match.group(1)) + site_num = int(rack.model.split("_")[-1]) + 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}" + ) + + async def stop(self): + 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("ST 1900") + await self._send_command("ST 1801") + await self._wait_ready() + + async def open_door(self): + await self._send_command("ST 1901") + await self._wait_ready() + + 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, **backend_kwargs + ): + """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(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("ST 1905") # plate to transfer station + 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, **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) + + 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("ST 1903") # terminate access + + async def move_position_to_position( + 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) + 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 # + + 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(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("ST 1908") # pick plate from origin position + + await self._wait_ready() + + if orig_m != dest_m: + 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("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("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("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(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.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() + if resp.startswith("E"): + logger.error(f"Command {command} failed with {resp}") + for member in ControllerError: + if resp == member.value: + cls, msg = controller_error_map[member] + raise cls(msg) + raise RuntimeError(f"Unknown error {resp} when sending command {command}") + 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("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. 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 + while time.time() < deadline: + resp = await self._send_command("RD 1915") + if resp == "1": + return + await asyncio.sleep(0.1) + err_flag = await self._send_command("RD 1814") + if err_flag == "1": + error = await self._send_command("RD DM200") + for member in HandlingError: + if error == member.value: + 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") + + 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(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""" + if self.model.value.split("_")[-1] == "NC": + raise NotImplementedError("Climate control is not supported on this model") + + resp = await self._send_command("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 shaker_status(self) -> int: + """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") + + async def get_shaker_speed(self) -> float: + """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 + + async def start_shaking(self, frequency): + """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") + 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() + + async def stop_shaking(self): + """Stop shaking. Uses command RS 1913. + + UNTESTED.""" + await self._send_command("RS 1913") + await self._wait_ready() + + 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") + + resp = await self._send_command("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 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 * 1000) # PLC uses 0.1% units: 0.9 fraction -> 900 -> 90.0% + await self._send_command(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 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("RD DM983") + try: + humidity_value = int(resp) + 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 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("RD DM893") + try: + humidity_value = int(resp) + 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}") + + 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. + + 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() + + async def get_co2_level(self) -> float: + """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) + 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}") + + 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). + + UNTESTED.""" + 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 + 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 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)}") + 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). + + UNTESTED.""" + 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 + return n2 + except ValueError: + raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") + + 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). + + UNTESTED.""" + 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 + return n2 + except ValueError: + raise RuntimeError(f"Invalid N2 set value received from incubator: {resp!r}") + + async def turn_swap_station(self, home: bool): + """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") + + async def check_shovel_sensor(self) -> bool: + """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") + if resp == "1": + return True + elif resp == "0": + return False + else: + raise RuntimeError(f"Unexpected response from incubator read shovel sensor: {resp!r}") + + async def check_transfer_sensor(self) -> bool: + """Check if the transfer plate sensor is activated. + + UNTESTED.""" + resp = await self._send_command("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}") + + async def check_second_transfer_sensor(self) -> bool: + """Check if the second transfer plate sensor is activated. + + UNTESTED.""" + resp = await self._send_command("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}") + + 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) + + 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}") + return barcode + + 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"], model=data["model"]) diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/storage/liconic/liconic_backend_tests.py new file mode 100644 index 00000000000..1cbdeea61fa --- /dev/null +++ b/pylabrobot/storage/liconic/liconic_backend_tests.py @@ -0,0 +1,411 @@ +# mypy: disable-error-code="attr-defined,method-assign" + +import unittest +from unittest.mock import AsyncMock + +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, + ExperimentalLiconicBackend, +) +from pylabrobot.storage.liconic.racks import ( + liconic_rack_5mm_42, + liconic_rack_17mm_22, + liconic_rack_44mm_10, +) + + +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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 TestShaking(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.backend = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(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 = 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") + 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 = ExperimentalLiconicBackend(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 = ExperimentalLiconicBackend(model=LiconicType.STX44_IC, port="/dev/ttyUSB0") + data = backend.serialize() + self.assertEqual(data["port"], "/dev/ttyUSB0") + self.assertEqual(data["model"], "STX44_IC") + + 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 = ExperimentalLiconicBackend.deserialize({"port": "/dev/ttyUSB0", "model": "STX44_IC"}) + self.assertEqual(restored.model, LiconicType.STX44_IC) + + +class TestErrorHandling(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.backend = ExperimentalLiconicBackend(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) as ctx: + await self.backend._send_command("ST 1801") + self.assertIn("Unknown error", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/storage/liconic/racks.py new file mode 100644 index 00000000000..1f943868deb --- /dev/null +++ b/pylabrobot/storage/liconic/racks.py @@ -0,0 +1,360 @@ +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateCarrier, PlateHolder + + +def _liconic_rack( + name: str, + site_height: int, + num_sites: int, + model: str, + total_height: int = 505, +): + start = 17.2 # rough height of first plate position + 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}", + 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): + """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, + site_height=5, + num_sites=55, + model="liconic_rack_5mm_55", + total_height=645, + ) + + +def liconic_rack_5mm_111(name: str): + """STX1000 bicarousel. Pitch 11mm, motor steps 377.""" + return _liconic_rack( + name=name, + site_height=5, + num_sites=111, + model="liconic_rack_5mm_111", + total_height=1210, + ) + + +def liconic_rack_11mm_28(name: str): + """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, + site_height=11, + num_sites=37, + model="liconic_rack_11mm_37", + total_height=645, + ) + + +def liconic_rack_11mm_72(name: str): + """STX1000 bicarousel. Pitch 17mm, motor steps 582.""" + return _liconic_rack( + name=name, + site_height=11, + num_sites=72, + model="liconic_rack_11mm_72", + total_height=1210, + ) + + +def liconic_rack_12mm_27(name: str): + """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, + site_height=12, + num_sites=35, + model="liconic_rack_12mm_35", + total_height=645, + ) + + +def liconic_rack_12mm_68(name: str): + """STX1000 bicarousel. Pitch 18mm, motor steps 617.""" + return _liconic_rack( + name=name, + site_height=12, + num_sites=68, + model="liconic_rack_12mm_68", + total_height=1210, + ) + + +def liconic_rack_17mm_22(name: str): + """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, + site_height=17, + num_sites=28, + model="liconic_rack_17mm_28", + total_height=645, + ) + + +def liconic_rack_17mm_53(name: str): + """STX1000 bicarousel. Pitch 23mm, motor steps 788.""" + return _liconic_rack( + name=name, + site_height=17, + num_sites=53, + model="liconic_rack_17mm_53", + total_height=1210, + ) + + +def liconic_rack_22mm_17(name: str): + """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, + site_height=22, + num_sites=23, + model="liconic_rack_22mm_23", + total_height=645, + ) + + +def liconic_rack_22mm_43(name: str): + """STX1000 bicarousel. Pitch 28mm, motor steps 959.""" + return _liconic_rack( + name=name, + site_height=22, + num_sites=43, + model="liconic_rack_22mm_43", + total_height=1210, + ) + + +def liconic_rack_23mm_17(name: str): + """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, + site_height=23, + num_sites=22, + model="liconic_rack_23mm_22", + total_height=645, + ) + + +def liconic_rack_23mm_42(name: str): + """STX1000 bicarousel. Pitch 29mm, motor steps 994.""" + return _liconic_rack( + name=name, + site_height=23, + num_sites=42, + model="liconic_rack_23mm_42", + total_height=1210, + ) + + +def liconic_rack_24mm_17(name: str): + """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, + site_height=24, + num_sites=21, + model="liconic_rack_24mm_21", + total_height=645, + ) + + +def liconic_rack_24mm_41(name: str): + """STX1000 bicarousel. Pitch 30mm, motor steps 1028.""" + return _liconic_rack( + name=name, + site_height=24, + num_sites=41, + model="liconic_rack_24mm_41", + total_height=1210, + ) + + +def liconic_rack_27mm_15(name: str): + """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, + site_height=27, + num_sites=19, + model="liconic_rack_27mm_19", + total_height=645, + ) + + +def liconic_rack_27mm_37(name: str): + """STX1000 bicarousel. Pitch 33mm, motor steps 1131.""" + return _liconic_rack( + name=name, + site_height=27, + num_sites=37, + model="liconic_rack_27mm_37", + total_height=1210, + ) + + +def liconic_rack_44mm_10(name: str): + """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, + site_height=44, + num_sites=13, + model="liconic_rack_44mm_13", + total_height=645, + ) + + +def liconic_rack_44mm_25(name: str): + """STX1000 bicarousel. Pitch 50mm, motor steps 1713.""" + return _liconic_rack( + name=name, + site_height=44, + num_sites=25, + model="liconic_rack_44mm_25", + total_height=1210, + ) + + +def liconic_rack_53mm_8(name: str): + """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, + site_height=53, + num_sites=10, + model="liconic_rack_53mm_10", + total_height=645, + ) + + +def liconic_rack_53mm_21(name: str): + """STX1000 bicarousel. Pitch 59mm, motor steps 2021.""" + return _liconic_rack( + name=name, + site_height=53, + num_sites=21, + model="liconic_rack_53mm_21", + total_height=1210, + ) + + +def liconic_rack_66mm_7(name: str): + """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, + site_height=66, + num_sites=8, + model="liconic_rack_66mm_8", + total_height=645, + ) + + +def liconic_rack_66mm_17(name: str): + """STX1000 bicarousel. Pitch 72mm, motor steps 2467.""" + return _liconic_rack( + name=name, + site_height=66, + num_sites=17, + model="liconic_rack_66mm_17", + total_height=1210, + ) + + +def liconic_rack_104mm_4(name: str): + """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, + site_height=104, + num_sites=5, + model="liconic_rack_104mm_5", + total_height=645, + ) + + +def liconic_rack_104mm_11(name: str): + """STX1000 bicarousel. Pitch 110mm, motor steps 3563.""" + return _liconic_rack( + name=name, + site_height=104, + num_sites=11, + model="liconic_rack_104mm_11", + total_height=1210, + )