diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 16eade89a54..95806c152cc 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -836,7 +836,10 @@ "active cooling" ], "status": "mostly", - "api_version": "v0", + "api": "pylabrobot.liconic.StoreX", + "api_version": "v1", + "code_slug": "liconic", + "doc_slug": "liconic/storex/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://liconic.com/products/stx.php" }, diff --git a/docs/api/pylabrobot.liconic.rst b/docs/api/pylabrobot.liconic.rst new file mode 100644 index 00000000000..eb173c04bf1 --- /dev/null +++ b/docs/api/pylabrobot.liconic.rst @@ -0,0 +1,21 @@ +.. currentmodule:: pylabrobot.liconic.storex + +pylabrobot.liconic package +========================== + +LiCONiC StoreX +-------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + StoreX + NoFreeSiteError + +Rack resources +-------------- + +.. automodule:: pylabrobot.liconic.storex.racks + :members: diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 67dd588713c..811f20d4dc9 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -32,6 +32,7 @@ Manufacturers pylabrobot.inheco pylabrobot.kbioscience pylabrobot.kbiosystems + pylabrobot.liconic pylabrobot.mettler_toledo pylabrobot.micronic pylabrobot.molecular_devices diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 69f74875f52..1e3961c43bd 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -40,6 +40,7 @@ high_res/index inheco/index kbioscience/index kbiosystems/index +liconic/index mettler_toledo/index micronic/index molecular_devices/index diff --git a/docs/user_guide/liconic/index.md b/docs/user_guide/liconic/index.md new file mode 100644 index 00000000000..d0569c01985 --- /dev/null +++ b/docs/user_guide/liconic/index.md @@ -0,0 +1,7 @@ +# LiCONiC + +```{toctree} +:maxdepth: 1 + +storex/hello-world +``` diff --git a/docs/user_guide/liconic/storex/hello-world.ipynb b/docs/user_guide/liconic/storex/hello-world.ipynb new file mode 100644 index 00000000000..bafcecbcaf7 --- /dev/null +++ b/docs/user_guide/liconic/storex/hello-world.ipynb @@ -0,0 +1,601 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "137e6aad", + "metadata": {}, + "source": [ + "# LiCONiC StoreX hello world\n", + "\n", + "```{device-card} liconic-stx\n", + "```\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Manufacturer | LiCONiC |\n", + "| Device family | StoreX |\n", + "| Supported product line | STX |\n", + "| Connection | RS-232 serial |\n", + "| Serial settings | 9600 baud, 8 data bits, even parity, 1 stop bit, RTS/CTS |\n", + "| PyLabRobot class | `StoreX` |\n", + "\n", + "STX, STR, and other names identify StoreX product lines. This driver accepts only known STX model identifiers because protocol compatibility with the other lines is not yet confirmed.\n", + "\n", + "STX climate suffixes:\n", + "\n", + "| Suffix | Climate type | Temperature | Active cooling | Independent humidity |\n", + "|---|---|---:|---:|---:|\n", + "| IC | Incubator | yes | no | no |\n", + "| HC | Humid cooler | yes | yes | no |\n", + "| DC2 | Dry storage | yes | no | yes |\n", + "| HR | Humid wide range | yes | yes | no |\n", + "| DR2 | Dry wide range | yes | no | yes |\n", + "| AR | Humidity controlled | yes | no | yes |\n", + "| DF | Deep freezer | yes | yes | no |\n", + "| NC | No climate | no | no | no |\n", + "| DH | Dry humid | yes | no | yes |" + ] + }, + { + "cell_type": "markdown", + "id": "392b6571", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "1. Connect the controller's RS-232 interface to the computer.\n", + "2. Confirm the serial port name.\n", + "3. Install the physical cassettes in exactly the same order as the `racks` list below. Controller cassette numbering is 1-based.\n", + "4. Keep the loading tray and handler clear during setup and initialization.\n", + "5. Enable the optional shaker or barcode scanner only when that hardware is installed.\n", + "\n", + "Cassette resources encode the plate pitch and number of sites. The configured list must match the physical instrument." + ] + }, + { + "cell_type": "markdown", + "id": "82c8c3d6", + "metadata": {}, + "source": [ + "## Configure the device\n", + "\n", + "This example uses an STX220 humidity-controlled variant. Replace the model, port, rack list, and loading-tray location with the physical configuration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cddae9df", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.liconic import StoreX\n", + "from pylabrobot.liconic.storex.racks import storex_rack_17mm_22, storex_rack_44mm_10\n", + "from pylabrobot.resources import Coordinate\n", + "\n", + "racks = [\n", + " storex_rack_44mm_10(\"cassette_1\"),\n", + " storex_rack_17mm_22(\"cassette_2\"),\n", + "]\n", + "\n", + "storex = StoreX(\n", + " name=\"storex\",\n", + " model=\"STX220_AR\",\n", + " port=\"/dev/ttyUSB0\",\n", + " racks=racks,\n", + " loading_tray_location=Coordinate.zero(),\n", + " has_shaker=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9ae69c16", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`setup()` opens the serial connection, performs the controller handshake, activates handling, and waits for the ready flag. It does not move a plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65fb2731", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "8b656a65", + "metadata": {}, + "source": [ + "## Initialize the handler\n", + "\n", + "Initialization homes and activates the handler. Confirm the handler and transfer station are clear first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fa0db8e", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.initialize()" + ] + }, + { + "cell_type": "markdown", + "id": "5daaa1c2", + "metadata": {}, + "source": [ + "## Store a plate\n", + "\n", + "First represent the physical plate on the loading tray. This only updates PyLabRobot's resource state; place the real plate on the tray before continuing." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb570916", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import cor_96_wellplate_360uL_Fb\n", + "\n", + "plate = cor_96_wellplate_360uL_Fb(\"assay_plate\")\n", + "storex.loading_tray.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "id": "c60360fb", + "metadata": {}, + "source": [ + "Store the tray plate in the smallest available cassette site that fits it. Pass `\"random\"` or a specific `PlateHolder` to choose another destination." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f6eb4acd", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.take_in_plate(\"smallest\")" + ] + }, + { + "cell_type": "markdown", + "id": "25afd45f", + "metadata": {}, + "source": [ + "## Retrieve a plate\n", + "\n", + "The plate is selected by its PyLabRobot resource name. Ensure the physical loading tray is empty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49b7177b", + "metadata": {}, + "outputs": [], + "source": [ + "retrieved = await storex.fetch_plate_to_loading_tray(\"assay_plate\")" + ] + }, + { + "cell_type": "markdown", + "id": "df8af5e5", + "metadata": {}, + "source": [ + "## Move a plate internally\n", + "\n", + "First put the plate back into a known source site." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f46ccd6b", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.take_in_plate(racks[1].sites[0])" + ] + }, + { + "cell_type": "markdown", + "id": "a20f544f", + "metadata": {}, + "source": [ + "Move the stored plate to another free internal site. Resource state changes only after the controller completes the move." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4427d97b", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.move_plate(\"assay_plate\", racks[1].sites[1])" + ] + }, + { + "cell_type": "markdown", + "id": "1c7b883f", + "metadata": {}, + "source": [ + "Inspect the current plate assignments for all configured cassettes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd13ff51", + "metadata": {}, + "outputs": [], + "source": [ + "print(storex.summary())" + ] + }, + { + "cell_type": "markdown", + "id": "9eeff5b3", + "metadata": {}, + "source": [ + "## Barcode scanning\n", + "\n", + "Pass a scanner with async `setup()`, `stop()`, and `scan_barcode()` methods as `barcode_scanner=` when constructing the StoreX. `KeyenceBarcodeScanner` is compatible when it matches the installed reader.\n", + "\n", + "Scanning moves the selected site to the reading position, waits for the lift, triggers the scanner, resets the shovel, and stores the result on the plate resource." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d80d9c96", + "metadata": {}, + "outputs": [], + "source": [ + "# barcode = await storex.scan_barcode(racks[1].sites[1])\n", + "# print(barcode)" + ] + }, + { + "cell_type": "markdown", + "id": "2dcafea7", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Every supported STX climate configuration except `_NC` accepts a temperature target in degrees Celsius." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb638085", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.set_temperature(37.0)" + ] + }, + { + "cell_type": "markdown", + "id": "c1b4d210", + "metadata": {}, + "source": [ + "Read the measured and target temperatures." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "070bba24", + "metadata": {}, + "outputs": [], + "source": [ + "current_temperature = await storex.request_current_temperature()\n", + "target_temperature = await storex.request_target_temperature()\n", + "print(current_temperature, target_temperature)" + ] + }, + { + "cell_type": "markdown", + "id": "289b2709", + "metadata": {}, + "source": [ + "## Humidity control\n", + "\n", + "The `_DC2`, `_DR2`, `_AR`, and `_DH` configurations support independent humidity control. Values are fractions from 0.0 to 1.0." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55bb7c40", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.set_humidity(0.90)" + ] + }, + { + "cell_type": "markdown", + "id": "4cf7fabb", + "metadata": {}, + "source": [ + "Read the measured and target relative-humidity fractions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "770f3bd9", + "metadata": {}, + "outputs": [], + "source": [ + "current_humidity = await storex.request_current_humidity()\n", + "target_humidity = await storex.request_target_humidity()\n", + "print(current_humidity, target_humidity)" + ] + }, + { + "cell_type": "markdown", + "id": "a1896ddd", + "metadata": {}, + "source": [ + "## CO₂ control\n", + "\n", + "Use these operations only when CO₂ control hardware is installed. Values are fractions, so `0.05` means 5%." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6912fc36", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.set_co2_level(0.05)\n", + "# current_co2 = await storex.request_co2_level()\n", + "# target_co2 = await storex.request_target_co2_level()" + ] + }, + { + "cell_type": "markdown", + "id": "e407a7f2", + "metadata": {}, + "source": [ + "## N₂ control\n", + "\n", + "Use these operations only when N₂ control hardware is installed. Values are fractions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99e6f813", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.set_n2_level(0.10)\n", + "# current_n2 = await storex.request_n2_level()\n", + "# target_n2 = await storex.request_target_n2_level()" + ] + }, + { + "cell_type": "markdown", + "id": "c38b0b9c", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "Construct the StoreX with `has_shaker=True` only when the optional shaker is installed. Frequency is in hertz and must be between 1 and 50." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9223c059", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.start_shaking(10.0)" + ] + }, + { + "cell_type": "markdown", + "id": "b4f9a833", + "metadata": {}, + "source": [ + "Stop continuous shaking before accessing plates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a73abf99", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "id": "1727d3b3", + "metadata": {}, + "source": [ + "A timed shake uses client-side timing and sends the stop command when the wait exits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d48e4f3a", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.shake(frequency=10.0, duration=30.0)" + ] + }, + { + "cell_type": "markdown", + "id": "ad8a8d6d", + "metadata": {}, + "source": [ + "## Plate sensors\n", + "\n", + "Read the shovel and primary transfer-station sensors when diagnosing plate presence." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f996f69", + "metadata": {}, + "outputs": [], + "source": [ + "shovel_has_plate = await storex.request_shovel_sensor()\n", + "transfer_has_plate = await storex.request_transfer_sensor()\n", + "print(shovel_has_plate, transfer_has_plate)" + ] + }, + { + "cell_type": "markdown", + "id": "6cd6cde8", + "metadata": {}, + "source": [ + "Read the optional second transfer-station sensor separately." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63b3b72d", + "metadata": {}, + "outputs": [], + "source": [ + "second_transfer_has_plate = await storex.request_second_transfer_sensor()" + ] + }, + { + "cell_type": "markdown", + "id": "d5189709", + "metadata": {}, + "source": [ + "## Swap station\n", + "\n", + "The position-specific methods read the current position and move only when needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75e76599", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.move_swap_station_swapped()" + ] + }, + { + "cell_type": "markdown", + "id": "7f90a003", + "metadata": {}, + "source": [ + "Return the swap station home after the external transfer is complete." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "724a39e2", + "metadata": {}, + "outputs": [], + "source": [ + "# await storex.move_swap_station_home()" + ] + }, + { + "cell_type": "markdown", + "id": "c1ac9dfa", + "metadata": {}, + "source": [ + "## Door\n", + "\n", + "Open the access door before external access." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fde7af7", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.open_door()" + ] + }, + { + "cell_type": "markdown", + "id": "4b78d1c7", + "metadata": {}, + "source": [ + "After access is complete and the path is clear, close the door." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6743607", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.close_door()" + ] + }, + { + "cell_type": "markdown", + "id": "717659b8", + "metadata": {}, + "source": [ + "## Disconnect\n", + "\n", + "Close the serial connections when finished." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ccd3c369", + "metadata": {}, + "outputs": [], + "source": [ + "await storex.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mypy.ini b/mypy.ini index 614e3886553..11f43d5f265 100644 --- a/mypy.ini +++ b/mypy.ini @@ -21,3 +21,9 @@ ignore_missing_imports = True [mypy-opentrons_shared_data.*] ignore_missing_imports = True + +[mypy-grpc.*] +ignore_missing_imports = True + +[mypy-google.protobuf.*] +ignore_missing_imports = True diff --git a/pylabrobot/liconic/__init__.py b/pylabrobot/liconic/__init__.py new file mode 100644 index 00000000000..4879ac87630 --- /dev/null +++ b/pylabrobot/liconic/__init__.py @@ -0,0 +1,7 @@ +from .storex import ( + STOREX_MODELS, + STOREX_SITE_HEIGHT_TO_STEPS, + NoFreeSiteError, + StoreX, + StoreXModel, +) diff --git a/pylabrobot/liconic/storex/__init__.py b/pylabrobot/liconic/storex/__init__.py new file mode 100644 index 00000000000..bbb9278bd29 --- /dev/null +++ b/pylabrobot/liconic/storex/__init__.py @@ -0,0 +1,2 @@ +from .constants import STOREX_MODELS, StoreXModel +from .storex import STOREX_SITE_HEIGHT_TO_STEPS, NoFreeSiteError, StoreX diff --git a/pylabrobot/liconic/storex/constants.py b/pylabrobot/liconic/storex/constants.py new file mode 100644 index 00000000000..75690d0899a --- /dev/null +++ b/pylabrobot/liconic/storex/constants.py @@ -0,0 +1,235 @@ +"""StoreX model identifiers and controller codes.""" + +from enum import Enum +from typing import Literal, Tuple + +# Protocol support is currently limited to the STX product line. Other StoreX +# lines must be verified before their model identifiers are added here. +StoreXModel = Literal[ + "STX44_IC", + "STX44_HC", + "STX44_DC2", + "STX44_HR", + "STX44_DR2", + "STX44_AR", + "STX44_DF", + "STX44_NC", + "STX44_DH", + "STX110_IC", + "STX110_HC", + "STX110_DC2", + "STX110_HR", + "STX110_DR2", + "STX110_AR", + "STX110_DF", + "STX110_NC", + "STX110_DH", + "STX220_IC", + "STX220_HC", + "STX220_DC2", + "STX220_HR", + "STX220_DR2", + "STX220_AR", + "STX220_DF", + "STX220_NC", + "STX220_DH", + "STX280_IC", + "STX280_HC", + "STX280_DC2", + "STX280_HR", + "STX280_DR2", + "STX280_AR", + "STX280_DF", + "STX280_NC", + "STX280_DH", + "STX500_IC", + "STX500_HC", + "STX500_DC2", + "STX500_HR", + "STX500_DR2", + "STX500_AR", + "STX500_DF", + "STX500_NC", + "STX500_DH", + "STX1000_IC", + "STX1000_HC", + "STX1000_DC2", + "STX1000_HR", + "STX1000_DR2", + "STX1000_AR", + "STX1000_DF", + "STX1000_NC", + "STX1000_DH", +] + +STOREX_MODELS: Tuple[StoreXModel, ...] = ( + "STX44_IC", + "STX44_HC", + "STX44_DC2", + "STX44_HR", + "STX44_DR2", + "STX44_AR", + "STX44_DF", + "STX44_NC", + "STX44_DH", + "STX110_IC", + "STX110_HC", + "STX110_DC2", + "STX110_HR", + "STX110_DR2", + "STX110_AR", + "STX110_DF", + "STX110_NC", + "STX110_DH", + "STX220_IC", + "STX220_HC", + "STX220_DC2", + "STX220_HR", + "STX220_DR2", + "STX220_AR", + "STX220_DF", + "STX220_NC", + "STX220_DH", + "STX280_IC", + "STX280_HC", + "STX280_DC2", + "STX280_HR", + "STX280_DR2", + "STX280_AR", + "STX280_DF", + "STX280_NC", + "STX280_DH", + "STX500_IC", + "STX500_HC", + "STX500_DC2", + "STX500_HR", + "STX500_DR2", + "STX500_AR", + "STX500_DF", + "STX500_NC", + "STX500_DH", + "STX1000_IC", + "STX1000_HC", + "STX1000_DC2", + "STX1000_HR", + "STX1000_DR2", + "STX1000_AR", + "STX1000_DF", + "STX1000_NC", + "STX1000_DH", +) + + +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/liconic/storex/errors.py b/pylabrobot/liconic/storex/errors.py new file mode 100644 index 00000000000..ecbbcc7e8ec --- /dev/null +++ b/pylabrobot/liconic/storex/errors.py @@ -0,0 +1,477 @@ +"""StoreX controller and plate-handler errors.""" + +from typing import Dict, Tuple, Type + +from .constants import ControllerError, HandlingError + + +class StoreXControllerRelayError(Exception): + pass + + +class StoreXControllerCommandError(Exception): + pass + + +class StoreXControllerProgramError(Exception): + pass + + +class StoreXControllerHardwareError(Exception): + pass + + +class StoreXControllerWriteProtectedError(Exception): + pass + + +class StoreXControllerBaseUnitError(Exception): + pass + + +controller_error_map: Dict[ControllerError, Tuple[Type[Exception], str]] = { + ControllerError.RELAY_ERROR: ( + StoreXControllerRelayError, + "Controller system error. Undefined timer, counter, data memory, check if requested unit is valid", + ), + ControllerError.COMMAND_ERROR: ( + StoreXControllerCommandError, + "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: ( + StoreXControllerProgramError, + "Controller system error. Firmware lost, reprogram controller", + ), + ControllerError.HARDWARE_ERROR: ( + StoreXControllerHardwareError, + "Controller hardware error, turn controller ON/OFF, controller is faulty has to be replaced", + ), + ControllerError.WRITE_PROTECTED_ERROR: ( + StoreXControllerWriteProtectedError, + "Controller system error. Unauthorized Access", + ), + ControllerError.BASE_UNIT_ERROR: ( + StoreXControllerBaseUnitError, + "Controller system error. Unauthorized Access", + ), +} + + +class StoreXHandlerPlateRemoveError(Exception): + pass + + +class StoreXHandlerBarcodeReadError(Exception): + pass + + +class StoreXHandlerPlatePlaceError(Exception): + pass + + +class StoreXHandlerPlateSetError(Exception): + pass + + +class StoreXHandlerPlateGetError(Exception): + pass + + +class StoreXHandlerImportPlateError(Exception): + pass + + +class StoreXHandlerExportPlateError(Exception): + pass + + +class StoreXHandlerGeneralError(Exception): + pass + + +handler_error_map: Dict[HandlingError, Tuple[Type[Exception], str]] = { + HandlingError.GENERAL_HANDLING_ERROR: ( + StoreXHandlerGeneralError, + "Handling action could not be performed in time", + ), + HandlingError.GATE_OPEN_ERROR: ( + StoreXHandlerGeneralError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.GATE_CLOSE_ERROR: ( + StoreXHandlerGeneralError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerGeneralError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.USER_ACCESS_ERROR: ( + StoreXHandlerGeneralError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.STACKER_SLOT_ERROR: ( + StoreXHandlerGeneralError, + "Stacker slot cannot be reached", + ), + HandlingError.REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerGeneralError, + "Undefined stacker level has been requested", + ), + HandlingError.PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerGeneralError, + "Export operation while plate is on transfer station", + ), + HandlingError.LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerGeneralError, + "Lift could not be initialized", + ), + HandlingError.PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerGeneralError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerGeneralError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.NO_RECOVERY: ( + StoreXHandlerGeneralError, + "Recovery was not possible", + ), + HandlingError.IMPORT_PLATE_STACKER_POSITIONING_ERROR: ( + StoreXHandlerImportPlateError, + "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: ( + StoreXHandlerImportPlateError, + "Handler could not reach outer turn position at transfer level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: ( + StoreXHandlerImportPlateError, + "Shovel could not reach outer position at transfer level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_LIFT_TRANSFER_ERROR: ( + StoreXHandlerImportPlateError, + "Lift did not reach upper pick position at transfer level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: ( + StoreXHandlerImportPlateError, + "Shovel could not reach inner position at transfer level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: ( + StoreXHandlerImportPlateError, + "Handler could not reach inner turn position at transfer level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: ( + StoreXHandlerImportPlateError, + "Lift could not reach desired stacker level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_FRONT_ERROR: ( + StoreXHandlerImportPlateError, + "Shovel could not reach front position on stacker access during Plate Import procedure.", + ), + HandlingError.IMPORT_PLATE_LIFT_STACKER_PLACE_ERROR: ( + StoreXHandlerImportPlateError, + "Lift could not reach stacker place level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: ( + StoreXHandlerImportPlateError, + "Shovel could not reach inner position at stacker plate placement during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: ( + StoreXHandlerImportPlateError, + "Lift could not reach zero level during Import Plate procedure.", + ), + HandlingError.IMPORT_PLATE_LIFT_INIT_ERROR: ( + StoreXHandlerImportPlateError, + "Lift could not be initialized after Import Plate procedure.", + ), + HandlingError.EXPORT_PLATE_LIFT_STACKER_TRAVEL_ERROR: ( + StoreXHandlerExportPlateError, + "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: ( + StoreXHandlerExportPlateError, + "Shovel could not reach front position on stacker access during Plate Export procedure.", + ), + HandlingError.EXPORT_PLATE_LIFT_STACKER_IMPORT_ERROR: ( + StoreXHandlerExportPlateError, + "Lift could not reach stacker pick level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_SHOVEL_STACKER_INNER_ERROR: ( + StoreXHandlerExportPlateError, + "Shovel could not reach inner position at stacker plate pick during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_POSITIONING_ERROR: ( + StoreXHandlerExportPlateError, + "Lift could not reach transfer level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_OUT_ERROR: ( + StoreXHandlerExportPlateError, + "Handler could not reach outer turn position at transfer level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_OUTER_ERROR: ( + StoreXHandlerExportPlateError, + "Shovel could not reach outer position at transfer level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_LIFT_TRANSFER_PLACE_ERROR: ( + StoreXHandlerExportPlateError, + "Lift did not reach lower place position at transfer level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_SHOVEL_TRANSFER_INNER_ERROR: ( + StoreXHandlerExportPlateError, + "Shovel could not reach inner position at transfer level during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_HANDLER_TRANSFER_TURN_IN_ERROR: ( + StoreXHandlerExportPlateError, + "Handler could not reach inner turn position at transfer level during Export Plate procedure", + ), + HandlingError.EXPORT_PLATE_LIFT_TRAVEL_BACK_ERROR: ( + StoreXHandlerExportPlateError, + "Lift could not reach Zero position during Export Plate procedure.", + ), + HandlingError.EXPORT_PLATE_LIFT_INITIALIZING_ERROR: ( + StoreXHandlerExportPlateError, + "Lift could not be initialized after Export Plate procedure.", + ), + HandlingError.PLATE_REMOVE_GENERAL_HANDLING_ERROR: ( + StoreXHandlerPlateRemoveError, + "Handling action could not be performed in time.", + ), + HandlingError.PLATE_REMOVE_GATE_OPEN_ERROR: ( + StoreXHandlerPlateRemoveError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.PLATE_REMOVE_GATE_CLOSE_ERROR: ( + StoreXHandlerPlateRemoveError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.PLATE_REMOVE_GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerPlateRemoveError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.PLATE_REMOVE_USER_ACCESS_ERROR: ( + StoreXHandlerPlateRemoveError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.PLATE_REMOVE_STACKER_SLOT_ERROR: ( + StoreXHandlerPlateRemoveError, + "Stacker slot cannot be reached", + ), + HandlingError.PLATE_REMOVE_REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerPlateRemoveError, + "Undefined stacker level has been requested", + ), + HandlingError.PLATE_REMOVE_PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerPlateRemoveError, + "Export operation while plate is on transfer station", + ), + HandlingError.PLATE_REMOVE_LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerPlateRemoveError, + "Lift could not be initialized", + ), + HandlingError.PLATE_REMOVE_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateRemoveError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.PLATE_REMOVE_NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateRemoveError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.PLATE_REMOVE_NO_RECOVERY: ( + StoreXHandlerPlateRemoveError, + "Recovery was not possible", + ), + HandlingError.BARCODE_READ_GENERAL_HANDLING_ERROR: ( + StoreXHandlerBarcodeReadError, + "Handling action could not be performed in time.", + ), + HandlingError.BARCODE_READ_GATE_OPEN_ERROR: ( + StoreXHandlerBarcodeReadError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.BARCODE_READ_GATE_CLOSE_ERROR: ( + StoreXHandlerBarcodeReadError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.BARCODE_READ_GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerBarcodeReadError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.BARCODE_READ_USER_ACCESS_ERROR: ( + StoreXHandlerBarcodeReadError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.BARCODE_READ_STACKER_SLOT_ERROR: ( + StoreXHandlerBarcodeReadError, + "Stacker slot cannot be reached", + ), + HandlingError.BARCODE_READ_REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerBarcodeReadError, + "Undefined stacker level has been requested", + ), + HandlingError.BARCODE_READ_PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerBarcodeReadError, + "Export operation while plate is on transfer station", + ), + HandlingError.BARCODE_READ_LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerBarcodeReadError, + "Lift could not be initialized", + ), + HandlingError.BARCODE_READ_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerBarcodeReadError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.BARCODE_READ_NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerBarcodeReadError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.BARCODE_READ_NO_RECOVERY: ( + StoreXHandlerBarcodeReadError, + "Recovery was not possible", + ), + HandlingError.PLATE_PLACE_GENERAL_HANDLING_ERROR: ( + StoreXHandlerPlatePlaceError, + "Handling action could not be performed in time.", + ), + HandlingError.PLATE_PLACE_GATE_OPEN_ERROR: ( + StoreXHandlerPlatePlaceError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.PLATE_PLACE_GATE_CLOSE_ERROR: ( + StoreXHandlerPlatePlaceError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.PLATE_PLACE_GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerPlatePlaceError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.PLATE_PLACE_USER_ACCESS_ERROR: ( + StoreXHandlerPlatePlaceError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.PLATE_PLACE_STACKER_SLOT_ERROR: ( + StoreXHandlerPlatePlaceError, + "Stacker slot cannot be reached", + ), + HandlingError.PLATE_PLACE_REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerPlatePlaceError, + "Undefined stacker level has been requested", + ), + HandlingError.PLATE_PLACE_PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerPlatePlaceError, + "Export operation while plate is on transfer station", + ), + HandlingError.PLATE_PLACE_LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerPlatePlaceError, + "Lift could not be initialized", + ), + HandlingError.PLATE_PLACE_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlatePlaceError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.PLATE_PLACE_NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlatePlaceError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.PLATE_PLACE_NO_RECOVERY: ( + StoreXHandlerPlatePlaceError, + "Recovery was not possible", + ), + HandlingError.PLATE_SET_GENERAL_HANDLING_ERROR: ( + StoreXHandlerPlateSetError, + "Handling action could not be performed in time.", + ), + HandlingError.PLATE_SET_GATE_OPEN_ERROR: ( + StoreXHandlerPlateSetError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.PLATE_SET_GATE_CLOSE_ERROR: ( + StoreXHandlerPlateSetError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.PLATE_SET_GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerPlateSetError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.PLATE_SET_USER_ACCESS_ERROR: ( + StoreXHandlerPlateSetError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.PLATE_SET_STACKER_SLOT_ERROR: ( + StoreXHandlerPlateSetError, + "Stacker slot cannot be reached", + ), + HandlingError.PLATE_SET_REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerPlateSetError, + "Undefined stacker level has been requested", + ), + HandlingError.PLATE_SET_PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerPlateSetError, + "Export operation while plate is on transfer station", + ), + HandlingError.PLATE_SET_LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerPlateSetError, + "Lift could not be initialized", + ), + HandlingError.PLATE_SET_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateSetError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.PLATE_SET_NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateSetError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.PLATE_SET_NO_RECOVERY: ( + StoreXHandlerPlateSetError, + "Recovery was not possible", + ), + HandlingError.PLATE_GET_GENERAL_HANDLING_ERROR: ( + StoreXHandlerPlateGetError, + "Handling action could not be performed in time.", + ), + HandlingError.PLATE_GET_GATE_OPEN_ERROR: ( + StoreXHandlerPlateGetError, + "Gate could not reach upper position or Gate did not reach upper position in time", + ), + HandlingError.PLATE_GET_GATE_CLOSE_ERROR: ( + StoreXHandlerPlateGetError, + "Gate could not reach lower position or Gate did not reach lower position in time", + ), + HandlingError.PLATE_GET_GENERAL_LIFT_POSITIONING_ERROR: ( + StoreXHandlerPlateGetError, + "Handler-Lift could not reach desired level position or does not move", + ), + HandlingError.PLATE_GET_USER_ACCESS_ERROR: ( + StoreXHandlerPlateGetError, + "Unauthorized user access in combination with manual rotation of carrousel", + ), + HandlingError.PLATE_GET_STACKER_SLOT_ERROR: ( + StoreXHandlerPlateGetError, + "Stacker slot cannot be reached", + ), + HandlingError.PLATE_GET_REMOTE_ACCESS_LEVEL_ERROR: ( + StoreXHandlerPlateGetError, + "Undefined stacker level has been requested", + ), + HandlingError.PLATE_GET_PLATE_TRANSFER_DETECTION_ERROR: ( + StoreXHandlerPlateGetError, + "Export operation while plate is on transfer station", + ), + HandlingError.PLATE_GET_LIFT_INITIALIZATION_ERROR: ( + StoreXHandlerPlateGetError, + "Lift could not be initialized", + ), + HandlingError.PLATE_GET_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateGetError, + "Trying to load a plate, when a plate is already on the shovel", + ), + HandlingError.PLATE_GET_NO_PLATE_ON_SHOVEL_DETECTION: ( + StoreXHandlerPlateGetError, + "Trying to remove or place plate with no plate on the shovel", + ), + HandlingError.PLATE_GET_NO_RECOVERY: ( + StoreXHandlerPlateGetError, + "Recovery was not possible during get plate", + ), +} diff --git a/pylabrobot/liconic/storex/experimental_server/README.md b/pylabrobot/liconic/storex/experimental_server/README.md new file mode 100644 index 00000000000..30e526a4b31 --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/README.md @@ -0,0 +1,197 @@ +# StoreX server + +The server owns one StoreX. It owns the serial connection, the cassettes, the +loading tray, and every plate inside that resource tree. A remote workcell owns +none of those objects. + +The loading tray is a protocol boundary. Resource ownership crosses it through +a claim, not through a shared Python object. + +```text +StoreX rack -> StoreX tray -> claim -> workcell handoff -> workcell destination + StoreX owns the plate token workcell owns the plate +``` + +`GetState` remains available while a hardware operation is running. Its +`active_operation` field identifies initialization, storage, or retrieval; the +resource snapshot represents the last completed logical state until that +operation finishes. + +This is a draft. It implements the storage and handoff path first. Climate, +shaker, door, authentication, durable inventory, and durable operation records +are intentionally outside the first slice. + +## Install + +Install the serial and server dependencies on the machine connected to the +StoreX: + +```bash +pip install 'PyLabRobot[liconic-server]' +``` + +The `liconic-server` extra composes the serial transport with PyLabRobot's shared +`distributed` extra. The latter owns the compatible `grpcio` and `protobuf` +runtime dependencies used by distributed APIs. + +The checked-in Python protobuf modules are generated from `storex.proto`. To +regenerate them while developing, install `grpcio-tools` or use the repository's +`dev` extra, then run: + +```bash +python -m grpc_tools.protoc \ + -I. \ + --python_out=. \ + --pyi_out=. \ + --grpc_python_out=. \ + pylabrobot/liconic/storex/experimental_server/storex.proto +python -m ruff check --fix --unsafe-fixes \ + pylabrobot/liconic/storex/experimental_server/storex_pb2*.py +python -m ruff format pylabrobot/liconic/storex/experimental_server/storex_pb2*.py +``` + +## Run the server + +Construct the StoreX exactly as it is physically configured, then give that +object to the server: + +```python +import asyncio + +from pylabrobot.liconic import StoreX +from pylabrobot.liconic.storex.experimental_server import StoreXServer +from pylabrobot.liconic.storex.racks import storex_rack_17mm_22 +from pylabrobot.resources import Coordinate + + +async def main() -> None: + storex = StoreX( + name="storex", + model="STX44_IC", + port="/dev/ttyUSB0", + racks=[storex_rack_17mm_22("cassette_1")], + loading_tray_location=Coordinate.zero(), + ) + server = StoreXServer(storex, host="127.0.0.1", port=50051) + await server.setup() + try: + await server.wait_for_termination() + finally: + await server.stop() + + +asyncio.run(main()) +``` + +`server.setup()` connects to the controller. It does not home the handler. Call +`Initialize` only after confirming that the handler and loading tray are clear. + +The default listener is loopback-only. Do not bind the draft server to an +untrusted network. It has no authentication or transport security. + +## Represent the handoff in a local workcell + +The workcell models one local `PlateHolder` at the physical StoreX handoff +coordinate. This holder belongs to the workcell. It is not the StoreX loading +tray and it does not expose the StoreX cassette tree. + +```python +from pylabrobot.resources import Coordinate, PlateHolder + +storex_handoff = PlateHolder( + name="storex_handoff", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, +) +deck.assign_child_resource( + storex_handoff, + location=Coordinate(950, 120, 145), +) +``` + +Use the same protobuf contract from a Python workcell client: + +```python +import json +import uuid + +import grpc + +from pylabrobot.liconic.storex.experimental_server import storex_pb2, storex_pb2_grpc +from pylabrobot.resources import Plate, Resource + +channel = grpc.aio.insecure_channel("127.0.0.1:50051") +storex = storex_pb2_grpc.StoreXServiceStub(channel) + +await storex.FetchPlate(storex_pb2.FetchPlateRequest(plate_name="assay_plate")) + +transfer_id = str(uuid.uuid4()) +claim = await storex.ClaimTrayPlate( + storex_pb2.ClaimTrayPlateRequest(transfer_id=transfer_id) +) + +resource = Resource.deserialize(json.loads(claim.plate.resource_json)) +assert isinstance(resource, Plate) +storex_handoff.assign_child_resource(resource) +``` + +At this point the StoreX resource tree no longer owns the plate. It retains a +reservation for `transfer_id`, so no other request can use the loading tray. +The workcell owns its deserialized `Plate` and can move it normally: + +```python +await liquid_handler.move_resource(resource, to=destination) +await storex.CompleteTransfer( + storex_pb2.TransferRequest(transfer_id=transfer_id) +) +``` + +`CompleteTransfer` succeeds only after the StoreX tray sensor is clear. +Repeated calls with the same transfer ID are safe. + +If the plate never left the tray, remove the local copy from the workcell model +and release the claim: + +```python +resource.unassign() +await storex.ReleaseTransfer( + storex_pb2.TransferRequest(transfer_id=transfer_id) +) +``` + +`ReleaseTransfer` succeeds only while the tray sensor still detects the plate. +If it is unclear whether the robot picked the plate, do not guess. Inspect the +workcell and reconcile both models explicitly. + +## Put a workcell plate into storage + +Place the physical plate on the StoreX tray and send its serialized snapshot. +Keep the local resource until registration succeeds; a retry with the same +snapshot is safe: + +```python +plate_json = json.dumps(resource.serialize()) + +await storex.RegisterTrayPlate( + storex_pb2.RegisterTrayPlateRequest(resource_json=plate_json) +) +resource.unassign() +await storex.StoreTrayPlate( + storex_pb2.StoreTrayPlateRequest( + plate_name=resource.name, + smallest_fit=storex_pb2.SmallestFit(), + ) +) +``` + +Registration requires the tray sensor to detect a physical plate. Storage then +moves the server-owned resource from the tray into the StoreX cassette tree. + +## Website ownership + +A StoreX-only website may fetch a plate to the StoreX tray, but that action does +not modify another process's workcell model. A cross-device button should call a +workcell coordinator. The coordinator performs the fetch, claim, local resource +assignment, robot move, and completion in that order. diff --git a/pylabrobot/liconic/storex/experimental_server/__init__.py b/pylabrobot/liconic/storex/experimental_server/__init__.py new file mode 100644 index 00000000000..9a08f594a7a --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/__init__.py @@ -0,0 +1,5 @@ +"""Experimental gRPC server for LiCONiC StoreX systems.""" + +from .server import StoreXServer, StoreXService + +__all__ = ["StoreXServer", "StoreXService"] diff --git a/pylabrobot/liconic/storex/experimental_server/server.py b/pylabrobot/liconic/storex/experimental_server/server.py new file mode 100644 index 00000000000..a58e312fba6 --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/server.py @@ -0,0 +1,486 @@ +"""Async gRPC server for a LiCONiC StoreX.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections import OrderedDict +from dataclasses import dataclass +from typing import Awaitable, Literal, NoReturn, Optional, TypeVar, cast + +try: + import grpc # type: ignore[import-untyped] +except ImportError as error: # pragma: no cover - depends on optional installation + raise ImportError( + "The StoreX server requires the 'liconic-server' optional dependencies. " + "Install PyLabRobot with `pip install 'PyLabRobot[liconic-server]'`." + ) from error + +from pylabrobot.liconic.storex.storex import NoFreeSiteError, StorageSite, StoreX +from pylabrobot.resources import Plate, PlateHolder, Resource, ResourceNotFoundError + +try: + from . import storex_pb2, storex_pb2_grpc +except ImportError as error: # pragma: no cover - depends on optional installation + raise ImportError( + "The StoreX server requires protobuf. " + "Install PyLabRobot with `pip install 'PyLabRobot[liconic-server]'`." + ) from error + + +logger = logging.getLogger(__name__) +_T = TypeVar("_T") +OperationName = Literal["initialize", "fetch_plate", "store_plate"] + + +async def _finish_hardware_operation(awaitable: Awaitable[_T]) -> _T: + """Keep a hardware operation and its lock alive after caller cancellation.""" + future: asyncio.Future[_T] = asyncio.ensure_future(awaitable) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + try: + await future + except Exception: + logger.exception("StoreX operation failed after its RPC was cancelled") + raise + + +@dataclass(frozen=True) +class _ActiveTransfer: + transfer_id: str + plate: Plate + resource_json: str + + +@dataclass(frozen=True) +class _ActiveOperation: + name: OperationName + plate_name: Optional[str] = None + + +class _ServiceError(Exception): + def __init__(self, code: grpc.StatusCode, details: str) -> None: + super().__init__(details) + self.code = code + self.details = details + + +class StoreXService(storex_pb2_grpc.StoreXServiceServicer): + """Translate StoreX RPCs into serialized operations on one device. + + The StoreX resource tree and the caller's workcell resource tree are separate. + Claiming a tray plate removes it from the StoreX tree and returns a serialized + copy. The tray remains reserved until the caller completes or releases the + transfer. + """ + + def __init__(self, storex: StoreX) -> None: + self.storex = storex + self._lock = asyncio.Lock() + self._active_transfer: Optional[_ActiveTransfer] = None + self._active_operation: Optional[_ActiveOperation] = None + self._finished_transfers: OrderedDict[str, Literal["completed", "released"]] = OrderedDict() + + @staticmethod + def _serialize_resource(resource: Resource) -> str: + return json.dumps(resource.serialize(), separators=(",", ":"), sort_keys=True) + + @classmethod + def _plate_snapshot(cls, plate: Plate, resource_json: Optional[str] = None): + return storex_pb2.PlateSnapshot( + name=plate.name, + resource_json=resource_json or cls._serialize_resource(plate), + ) + + def _remember_transfer(self, transfer_id: str, outcome: Literal["completed", "released"]) -> None: + self._finished_transfers[transfer_id] = outcome + self._finished_transfers.move_to_end(transfer_id) + while len(self._finished_transfers) > 128: + self._finished_transfers.popitem(last=False) + + def _require_no_active_transfer(self) -> None: + if self._active_transfer is not None: + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Loading tray is reserved by transfer {self._active_transfer.transfer_id!r}", + ) + + async def _run_hardware_operation( + self, + name: OperationName, + awaitable: Awaitable[_T], + plate_name: Optional[str] = None, + ) -> _T: + """Expose one in-flight hardware operation while retaining cancellation safety.""" + if self._active_operation is not None: + raise RuntimeError(f"StoreX operation {self._active_operation.name!r} is already active") + self._active_operation = _ActiveOperation(name=name, plate_name=plate_name) + try: + return await _finish_hardware_operation(awaitable) + finally: + self._active_operation = None + + def _site_from_address(self, address) -> PlateHolder: + if address.cassette < 1 or address.cassette > len(self.storex.racks): + raise _ServiceError( + grpc.StatusCode.INVALID_ARGUMENT, + f"Cassette must be between 1 and {len(self.storex.racks)}", + ) + rack = self.storex.racks[address.cassette - 1] + if address.position < 1: + raise _ServiceError( + grpc.StatusCode.INVALID_ARGUMENT, + "Position must be at least 1", + ) + try: + return cast(PlateHolder, rack.sites[address.position - 1]) + except KeyError: + raise _ServiceError( + grpc.StatusCode.INVALID_ARGUMENT, + f"Cassette {address.cassette} has no position {address.position}", + ) + + def _address_from_site(self, site: PlateHolder): + cassette, position = self.storex._site_to_address(site) + return storex_pb2.SiteAddress(cassette=cassette, position=position) + + async def _abort(self, context: grpc.aio.ServicerContext, error: Exception) -> NoReturn: + if isinstance(error, _ServiceError): + code = error.code + details = error.details + elif isinstance(error, ResourceNotFoundError): + code = grpc.StatusCode.NOT_FOUND + details = str(error) + elif isinstance(error, NoFreeSiteError): + code = grpc.StatusCode.RESOURCE_EXHAUSTED + details = str(error) + elif isinstance(error, NotImplementedError): + code = grpc.StatusCode.UNIMPLEMENTED + details = str(error) + elif isinstance(error, TimeoutError): + code = grpc.StatusCode.DEADLINE_EXCEEDED + details = str(error) + elif isinstance(error, (ValueError, TypeError)): + code = grpc.StatusCode.FAILED_PRECONDITION + details = str(error) + else: + code = grpc.StatusCode.INTERNAL + details = str(error) + await context.abort(code, details) + raise AssertionError("gRPC context.abort returned") + + async def GetState(self, request, context): + del request, context + response = storex_pb2.StoreXState(storex_resource_json=self._serialize_resource(self.storex)) + if self._active_transfer is not None: + response.active_transfer.transfer_id = self._active_transfer.transfer_id + response.active_transfer.plate_name = self._active_transfer.plate.name + if self._active_operation is not None: + response.active_operation.name = self._active_operation.name + if self._active_operation.plate_name is not None: + response.active_operation.plate_name = self._active_operation.plate_name + return response + + async def Initialize(self, request, context): + del request + try: + async with self._lock: + self._require_no_active_transfer() + if self.storex.loading_tray.resource is not None: + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "Cannot initialize while the loading tray owns a plate", + ) + if await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "Cannot initialize while the loading-tray sensor detects a plate", + ) + await self._run_hardware_operation("initialize", self.storex.initialize()) + return storex_pb2.Empty() + except Exception as error: + await self._abort(context, error) + + async def FetchPlate(self, request, context): + try: + if not request.plate_name: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "plate_name is required") + async with self._lock: + self._require_no_active_transfer() + tray_plate = self.storex.loading_tray.resource + if tray_plate is not None: + if isinstance(tray_plate, Plate) and tray_plate.name == request.plate_name: + return self._plate_snapshot(tray_plate) + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Loading tray is occupied by {tray_plate.name!r}", + ) + plate = await self._run_hardware_operation( + "fetch_plate", + self.storex.fetch_plate_to_loading_tray( + request.plate_name, + read_barcode=request.read_barcode, + ), + plate_name=request.plate_name, + ) + return self._plate_snapshot(plate) + except Exception as error: + await self._abort(context, error) + + async def ClaimTrayPlate(self, request, context): + try: + if not request.transfer_id: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "transfer_id is required") + async with self._lock: + if self._active_transfer is not None: + if self._active_transfer.transfer_id == request.transfer_id: + return storex_pb2.ClaimTrayPlateResponse( + transfer_id=request.transfer_id, + plate=self._plate_snapshot( + self._active_transfer.plate, + self._active_transfer.resource_json, + ), + ) + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Loading tray is reserved by transfer {self._active_transfer.transfer_id!r}", + ) + if request.transfer_id in self._finished_transfers: + raise _ServiceError( + grpc.StatusCode.ALREADY_EXISTS, + f"Transfer {request.transfer_id!r} is already finished", + ) + plate = self.storex.loading_tray.resource + if not isinstance(plate, Plate): + raise _ServiceError(grpc.StatusCode.NOT_FOUND, "No plate is registered on the tray") + if not await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "The loading-tray sensor does not detect the registered plate", + ) + resource_json = self._serialize_resource(plate) + plate.unassign() + self._active_transfer = _ActiveTransfer( + transfer_id=request.transfer_id, + plate=plate, + resource_json=resource_json, + ) + return storex_pb2.ClaimTrayPlateResponse( + transfer_id=request.transfer_id, + plate=self._plate_snapshot(plate, resource_json), + ) + except Exception as error: + await self._abort(context, error) + + async def CompleteTransfer(self, request, context): + try: + if not request.transfer_id: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "transfer_id is required") + async with self._lock: + outcome = self._finished_transfers.get(request.transfer_id) + if outcome == "completed": + return storex_pb2.Empty() + if outcome == "released": + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Transfer {request.transfer_id!r} was released", + ) + transfer = self._active_transfer + if transfer is None or transfer.transfer_id != request.transfer_id: + raise _ServiceError(grpc.StatusCode.NOT_FOUND, "Active transfer not found") + if await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "The loading-tray sensor still detects a plate", + ) + self._active_transfer = None + self._remember_transfer(request.transfer_id, "completed") + return storex_pb2.Empty() + except Exception as error: + await self._abort(context, error) + + async def ReleaseTransfer(self, request, context): + try: + if not request.transfer_id: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "transfer_id is required") + async with self._lock: + outcome = self._finished_transfers.get(request.transfer_id) + if outcome == "released": + return storex_pb2.Empty() + if outcome == "completed": + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Transfer {request.transfer_id!r} was completed", + ) + transfer = self._active_transfer + if transfer is None or transfer.transfer_id != request.transfer_id: + raise _ServiceError(grpc.StatusCode.NOT_FOUND, "Active transfer not found") + if not await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "Cannot release ownership because the tray sensor does not detect the plate", + ) + if self.storex.loading_tray.resource is not None: + raise _ServiceError( + grpc.StatusCode.INTERNAL, + "The loading tray acquired another resource during an active transfer", + ) + self.storex.loading_tray.assign_child_resource(transfer.plate) + self._active_transfer = None + self._remember_transfer(request.transfer_id, "released") + return storex_pb2.Empty() + except Exception as error: + await self._abort(context, error) + + async def RegisterTrayPlate(self, request, context): + try: + if not request.resource_json: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "resource_json is required") + try: + resource_data = json.loads(request.resource_json) + resource = Resource.deserialize(resource_data) + except (KeyError, TypeError, ValueError) as error: + raise _ServiceError( + grpc.StatusCode.INVALID_ARGUMENT, + f"Invalid serialized resource: {error}", + ) + if not isinstance(resource, Plate): + raise _ServiceError( + grpc.StatusCode.INVALID_ARGUMENT, + "resource_json must contain a Plate", + ) + + async with self._lock: + self._require_no_active_transfer() + tray_plate = self.storex.loading_tray.resource + if tray_plate is not None: + if ( + isinstance(tray_plate, Plate) + and tray_plate.name == resource.name + and self._serialize_resource(tray_plate) == self._serialize_resource(resource) + ): + return self._plate_snapshot(tray_plate) + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Loading tray is already occupied by {tray_plate.name!r}", + ) + if not await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "The loading-tray sensor does not detect a plate", + ) + self.storex.loading_tray.assign_child_resource(resource) + return self._plate_snapshot(resource) + except Exception as error: + await self._abort(context, error) + + async def StoreTrayPlate(self, request, context): + try: + if not request.plate_name: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "plate_name is required") + destination_kind = request.WhichOneof("destination") + if destination_kind is None: + raise _ServiceError(grpc.StatusCode.INVALID_ARGUMENT, "destination is required") + + async with self._lock: + self._require_no_active_transfer() + tray_plate = self.storex.loading_tray.resource + if tray_plate is None: + try: + existing_site = self.storex.get_site_by_plate_name(request.plate_name) + except ResourceNotFoundError: + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Plate {request.plate_name!r} is neither on the tray nor in storage", + ) + if destination_kind == "site" and existing_site is not self._site_from_address( + request.site + ): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Plate {request.plate_name!r} is already stored at a different site", + ) + existing_plate = cast(Plate, existing_site.resource) + return storex_pb2.StoredPlate( + plate=self._plate_snapshot(existing_plate), + site=self._address_from_site(existing_site), + ) + if not isinstance(tray_plate, Plate) or tray_plate.name != request.plate_name: + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + f"Loading tray does not contain plate {request.plate_name!r}", + ) + if not await self.storex.request_transfer_sensor(): + raise _ServiceError( + grpc.StatusCode.FAILED_PRECONDITION, + "The loading-tray sensor does not detect the registered plate", + ) + + if destination_kind == "site": + destination: StorageSite = self._site_from_address(request.site) + elif destination_kind == "random_fit": + destination = "random" + else: + destination = "smallest" + plate = await self._run_hardware_operation( + "store_plate", + self.storex.take_in_plate( + destination, + read_barcode=request.read_barcode, + ), + plate_name=request.plate_name, + ) + site = self.storex.get_site_by_plate_name(plate.name) + return storex_pb2.StoredPlate( + plate=self._plate_snapshot(plate), + site=self._address_from_site(site), + ) + except Exception as error: + await self._abort(context, error) + + +class StoreXServer: + """Own one StoreX connection and expose it through an async gRPC server.""" + + def __init__( + self, + storex: StoreX, + host: str = "127.0.0.1", + port: int = 50051, + ) -> None: + self.storex = storex + self.host = host + self._server = grpc.aio.server() + self.service = StoreXService(storex) + storex_pb2_grpc.add_StoreXServiceServicer_to_server(self.service, self._server) + self.port = self._server.add_insecure_port(f"{host}:{port}") + if self.port == 0: + raise RuntimeError(f"Could not bind StoreX gRPC server to {host}:{port}") + self._started = False + + async def setup(self) -> None: + """Connect to the StoreX, then start accepting RPCs.""" + if self._started: + return + await self.storex.setup() + try: + await self._server.start() + except Exception: + await self.storex.stop() + raise + self._started = True + + async def stop(self, grace: float = 5.0) -> None: + """Stop accepting RPCs, drain calls, and close the StoreX connection.""" + if not self._started: + return + try: + await self._server.stop(grace) + finally: + await self.storex.stop() + self._started = False + + async def wait_for_termination(self) -> None: + await self._server.wait_for_termination() diff --git a/pylabrobot/liconic/storex/experimental_server/server_tests.py b/pylabrobot/liconic/storex/experimental_server/server_tests.py new file mode 100644 index 00000000000..4ddf617b85f --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/server_tests.py @@ -0,0 +1,264 @@ +"""Tests for the experimental StoreX gRPC server.""" + +import asyncio +import json +import unittest +from typing import NoReturn +from unittest.mock import AsyncMock + +import grpc # type: ignore[import-untyped] + +from pylabrobot.liconic.storex.racks import storex_rack_17mm_22 +from pylabrobot.liconic.storex.storex import StoreX +from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource +from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb + +from . import storex_pb2, storex_pb2_grpc +from .server import StoreXServer, StoreXService + + +class RpcAbort(Exception): + def __init__(self, code: grpc.StatusCode, details: str) -> None: + super().__init__(details) + self.code = code + self.details = details + + +class FakeContext: + async def abort(self, code: grpc.StatusCode, details: str) -> NoReturn: + raise RpcAbort(code, details) + + +def make_storex() -> StoreX: + return StoreX( + name="storex", + model="STX44_IC", + port="/dev/null", + racks=[storex_rack_17mm_22("cassette_1")], + loading_tray_location=Coordinate.zero(), + ) + + +def make_plate(name: str = "plate") -> Plate: + return cor_96_wellplate_360uL_Fb(name=name) + + +class TestTransferOwnership(unittest.IsolatedAsyncioTestCase): + async def test_claim_moves_ownership_to_distinct_workcell_resource(self) -> None: + storex = make_storex() + server_plate = make_plate("assay_plate") + storex.racks[0].sites[0].assign_child_resource(server_plate) + + async def fetch(plate_name: str, read_barcode: bool = False) -> Plate: + del read_barcode + site = storex.get_site_by_plate_name(plate_name) + plate = site.resource + assert isinstance(plate, Plate) + plate.unassign() + storex.loading_tray.assign_child_resource(plate) + return plate + + storex.fetch_plate_to_loading_tray = AsyncMock(side_effect=fetch) # type: ignore[method-assign] + storex.request_transfer_sensor = AsyncMock(side_effect=[True, False]) # type: ignore[method-assign] + service = StoreXService(storex) + context = FakeContext() + + await service.FetchPlate( + storex_pb2.FetchPlateRequest(plate_name="assay_plate"), + context, + ) + claim = await service.ClaimTrayPlate( + storex_pb2.ClaimTrayPlateRequest(transfer_id="transfer-1"), + context, + ) + + self.assertIsNone(server_plate.parent) + self.assertIsNone(storex.loading_tray.resource) + + local_plate = Resource.deserialize(json.loads(claim.plate.resource_json)) + self.assertIsInstance(local_plate, Plate) + self.assertIsNot(local_plate, server_plate) + handoff = PlateHolder("storex_handoff", 127.76, 85.48, 0, pedestal_size_z=0) + handoff.assign_child_resource(local_plate) + + await service.CompleteTransfer( + storex_pb2.TransferRequest(transfer_id="transfer-1"), + context, + ) + state = await service.GetState(storex_pb2.Empty(), context) + + self.assertIs(handoff.resource, local_plate) + self.assertFalse(state.HasField("active_transfer")) + + async def test_claim_retry_returns_same_transfer(self) -> None: + storex = make_storex() + plate = make_plate() + storex.loading_tray.assign_child_resource(plate) + storex.request_transfer_sensor = AsyncMock(return_value=True) # type: ignore[method-assign] + service = StoreXService(storex) + request = storex_pb2.ClaimTrayPlateRequest(transfer_id="transfer-1") + + first = await service.ClaimTrayPlate(request, FakeContext()) + second = await service.ClaimTrayPlate(request, FakeContext()) + + self.assertEqual(first, second) + storex.request_transfer_sensor.assert_awaited_once() + + async def test_release_restores_storex_ownership(self) -> None: + storex = make_storex() + plate = make_plate() + storex.loading_tray.assign_child_resource(plate) + storex.request_transfer_sensor = AsyncMock(side_effect=[True, True]) # type: ignore[method-assign] + service = StoreXService(storex) + context = FakeContext() + request = storex_pb2.TransferRequest(transfer_id="transfer-1") + + await service.ClaimTrayPlate( + storex_pb2.ClaimTrayPlateRequest(transfer_id="transfer-1"), + context, + ) + await service.ReleaseTransfer(request, context) + await service.ReleaseTransfer(request, context) + + self.assertIs(storex.loading_tray.resource, plate) + self.assertEqual(storex.request_transfer_sensor.await_count, 2) + + async def test_claim_without_plate_returns_not_found(self) -> None: + service = StoreXService(make_storex()) + + with self.assertRaises(RpcAbort) as error: + await service.ClaimTrayPlate( + storex_pb2.ClaimTrayPlateRequest(transfer_id="transfer-1"), + FakeContext(), + ) + + self.assertEqual(error.exception.code, grpc.StatusCode.NOT_FOUND) + + async def test_cancelled_fetch_finishes_before_releasing_device_lock(self) -> None: + storex = make_storex() + plate = make_plate() + storex.racks[0].sites[0].assign_child_resource(plate) + started = asyncio.Event() + finish = asyncio.Event() + + async def fetch(plate_name: str, read_barcode: bool = False) -> Plate: + del read_barcode + started.set() + await finish.wait() + site = storex.get_site_by_plate_name(plate_name) + fetched_plate = site.resource + assert isinstance(fetched_plate, Plate) + fetched_plate.unassign() + storex.loading_tray.assign_child_resource(fetched_plate) + return fetched_plate + + storex.fetch_plate_to_loading_tray = AsyncMock(side_effect=fetch) # type: ignore[method-assign] + service = StoreXService(storex) + rpc = asyncio.create_task( + service.FetchPlate( + storex_pb2.FetchPlateRequest(plate_name="plate"), + FakeContext(), + ) + ) + await started.wait() + + rpc.cancel() + await asyncio.sleep(0) + self.assertFalse(rpc.done()) + finish.set() + with self.assertRaises(asyncio.CancelledError): + await rpc + + self.assertIs(storex.loading_tray.resource, plate) + + +class TestStorePlate(unittest.IsolatedAsyncioTestCase): + async def test_register_and_store_creates_server_owned_plate(self) -> None: + storex = make_storex() + local_plate = make_plate("incoming") + + async def take_in(site, read_barcode: bool = False) -> Plate: + del read_barcode + plate = storex.loading_tray.resource + assert isinstance(plate, Plate) + plate.unassign() + site.assign_child_resource(plate) + return plate + + storex.request_transfer_sensor = AsyncMock(return_value=True) # type: ignore[method-assign] + storex.take_in_plate = AsyncMock(side_effect=take_in) # type: ignore[method-assign] + service = StoreXService(storex) + context = FakeContext() + + await service.RegisterTrayPlate( + storex_pb2.RegisterTrayPlateRequest( + resource_json=json.dumps(local_plate.serialize()), + ), + context, + ) + result = await service.StoreTrayPlate( + storex_pb2.StoreTrayPlateRequest( + plate_name="incoming", + site=storex_pb2.SiteAddress(cassette=1, position=2), + ), + context, + ) + + stored_plate = storex.racks[0].sites[1].resource + self.assertIsInstance(stored_plate, Plate) + self.assertIsNot(stored_plate, local_plate) + self.assertEqual(result.site.cassette, 1) + self.assertEqual(result.site.position, 2) + + +class TestGrpcLifecycle(unittest.IsolatedAsyncioTestCase): + async def test_get_state_reports_initialization_without_waiting_for_it(self) -> None: + storex = make_storex() + storex.request_transfer_sensor = AsyncMock(return_value=False) # type: ignore[method-assign] + started = asyncio.Event() + finish = asyncio.Event() + + async def initialize() -> None: + started.set() + await finish.wait() + + storex.initialize = AsyncMock(side_effect=initialize) # type: ignore[method-assign] + service = StoreXService(storex) + initialization = asyncio.create_task(service.Initialize(storex_pb2.Empty(), FakeContext())) + await started.wait() + + state = await asyncio.wait_for( + service.GetState(storex_pb2.Empty(), FakeContext()), + timeout=0.1, + ) + + self.assertTrue(state.HasField("active_operation")) + self.assertEqual(state.active_operation.name, "initialize") + self.assertFalse(state.active_operation.HasField("plate_name")) + finish.set() + await initialization + state = await service.GetState(storex_pb2.Empty(), FakeContext()) + self.assertFalse(state.HasField("active_operation")) + + async def test_in_process_client_can_read_state(self) -> None: + storex = make_storex() + storex.setup = AsyncMock() # type: ignore[method-assign] + storex.stop = AsyncMock() # type: ignore[method-assign] + server = StoreXServer(storex, port=0) + await server.setup() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{server.port}") + try: + stub = storex_pb2_grpc.StoreXServiceStub(channel) + state = await stub.GetState(storex_pb2.Empty()) + serialized = json.loads(state.storex_resource_json) + self.assertEqual(serialized["name"], "storex") + finally: + await channel.close() + await server.stop(grace=0) + + storex.setup.assert_awaited_once() + storex.stop.assert_awaited_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/liconic/storex/experimental_server/storex.proto b/pylabrobot/liconic/storex/experimental_server/storex.proto new file mode 100644 index 00000000000..2934c00e685 --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/storex.proto @@ -0,0 +1,93 @@ +syntax = "proto3"; + +package pylabrobot.liconic.storex.v1; + +// The service owns the StoreX resource tree. A workcell receives a copy of a +// plate only through the loading-tray transfer protocol. +service StoreXService { + rpc GetState(Empty) returns (StoreXState); + rpc Initialize(Empty) returns (Empty); + + rpc FetchPlate(FetchPlateRequest) returns (PlateSnapshot); + rpc ClaimTrayPlate(ClaimTrayPlateRequest) returns (ClaimTrayPlateResponse); + rpc CompleteTransfer(TransferRequest) returns (Empty); + rpc ReleaseTransfer(TransferRequest) returns (Empty); + + rpc RegisterTrayPlate(RegisterTrayPlateRequest) returns (PlateSnapshot); + rpc StoreTrayPlate(StoreTrayPlateRequest) returns (StoredPlate); +} + +message Empty {} + +message PlateSnapshot { + string name = 1; + string resource_json = 2; +} + +message ActiveTransfer { + string transfer_id = 1; + string plate_name = 2; +} + +message ActiveOperation { + string name = 1; + optional string plate_name = 2; +} + +message StoreXState { + string storex_resource_json = 1; + optional ActiveTransfer active_transfer = 2; + optional ActiveOperation active_operation = 3; +} + +message FetchPlateRequest { + string plate_name = 1; + bool read_barcode = 2; +} + +message ClaimTrayPlateRequest { + // Generated by the caller. Retrying with the same ID returns the same claim. + string transfer_id = 1; +} + +message ClaimTrayPlateResponse { + string transfer_id = 1; + PlateSnapshot plate = 2; +} + +message TransferRequest { + string transfer_id = 1; +} + +message RegisterTrayPlateRequest { + // A serialized pylabrobot.resources.Plate owned by the calling workcell. + string resource_json = 1; +} + +message SiteAddress { + // Both fields are one-based, matching the StoreX controller and cassette labels. + uint32 cassette = 1; + uint32 position = 2; +} + +message SmallestFit {} + +message RandomFit {} + +message StoreTrayPlateRequest { + // Makes a retried request observable when the plate is already in storage. + string plate_name = 1; + + oneof destination { + SiteAddress site = 2; + SmallestFit smallest_fit = 3; + RandomFit random_fit = 4; + } + + bool read_barcode = 5; +} + +message StoredPlate { + PlateSnapshot plate = 1; + SiteAddress site = 2; +} diff --git a/pylabrobot/liconic/storex/experimental_server/storex_pb2.py b/pylabrobot/liconic/storex/experimental_server/storex_pb2.py new file mode 100644 index 00000000000..020373b1b3f --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/storex_pb2.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: pylabrobot/liconic/storex/experimental_server/storex.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n:pylabrobot/liconic/storex/experimental_server/storex.proto\x12\x1cpylabrobot.liconic.storex.v1"\x07\n\x05\x45mpty"4\n\rPlateSnapshot\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x15\n\rresource_json\x18\x02 \x01(\t"9\n\x0e\x41\x63tiveTransfer\x12\x13\n\x0btransfer_id\x18\x01 \x01(\t\x12\x12\n\nplate_name\x18\x02 \x01(\t"G\n\x0f\x41\x63tiveOperation\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\nplate_name\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\r\n\x0b_plate_name"\xee\x01\n\x0bStoreXState\x12\x1c\n\x14storex_resource_json\x18\x01 \x01(\t\x12J\n\x0f\x61\x63tive_transfer\x18\x02 \x01(\x0b\x32,.pylabrobot.liconic.storex.v1.ActiveTransferH\x00\x88\x01\x01\x12L\n\x10\x61\x63tive_operation\x18\x03 \x01(\x0b\x32-.pylabrobot.liconic.storex.v1.ActiveOperationH\x01\x88\x01\x01\x42\x12\n\x10_active_transferB\x13\n\x11_active_operation"=\n\x11\x46\x65tchPlateRequest\x12\x12\n\nplate_name\x18\x01 \x01(\t\x12\x14\n\x0cread_barcode\x18\x02 \x01(\x08",\n\x15\x43laimTrayPlateRequest\x12\x13\n\x0btransfer_id\x18\x01 \x01(\t"i\n\x16\x43laimTrayPlateResponse\x12\x13\n\x0btransfer_id\x18\x01 \x01(\t\x12:\n\x05plate\x18\x02 \x01(\x0b\x32+.pylabrobot.liconic.storex.v1.PlateSnapshot"&\n\x0fTransferRequest\x12\x13\n\x0btransfer_id\x18\x01 \x01(\t"1\n\x18RegisterTrayPlateRequest\x12\x15\n\rresource_json\x18\x01 \x01(\t"1\n\x0bSiteAddress\x12\x10\n\x08\x63\x61ssette\x18\x01 \x01(\r\x12\x10\n\x08position\x18\x02 \x01(\r"\r\n\x0bSmallestFit"\x0b\n\tRandomFit"\x8d\x02\n\x15StoreTrayPlateRequest\x12\x12\n\nplate_name\x18\x01 \x01(\t\x12\x39\n\x04site\x18\x02 \x01(\x0b\x32).pylabrobot.liconic.storex.v1.SiteAddressH\x00\x12\x41\n\x0csmallest_fit\x18\x03 \x01(\x0b\x32).pylabrobot.liconic.storex.v1.SmallestFitH\x00\x12=\n\nrandom_fit\x18\x04 \x01(\x0b\x32\'.pylabrobot.liconic.storex.v1.RandomFitH\x00\x12\x14\n\x0cread_barcode\x18\x05 \x01(\x08\x42\r\n\x0b\x64\x65stination"\x82\x01\n\x0bStoredPlate\x12:\n\x05plate\x18\x01 \x01(\x0b\x32+.pylabrobot.liconic.storex.v1.PlateSnapshot\x12\x37\n\x04site\x18\x02 \x01(\x0b\x32).pylabrobot.liconic.storex.v1.SiteAddress2\xe7\x06\n\rStoreXService\x12Z\n\x08GetState\x12#.pylabrobot.liconic.storex.v1.Empty\x1a).pylabrobot.liconic.storex.v1.StoreXState\x12V\n\nInitialize\x12#.pylabrobot.liconic.storex.v1.Empty\x1a#.pylabrobot.liconic.storex.v1.Empty\x12j\n\nFetchPlate\x12/.pylabrobot.liconic.storex.v1.FetchPlateRequest\x1a+.pylabrobot.liconic.storex.v1.PlateSnapshot\x12{\n\x0e\x43laimTrayPlate\x12\x33.pylabrobot.liconic.storex.v1.ClaimTrayPlateRequest\x1a\x34.pylabrobot.liconic.storex.v1.ClaimTrayPlateResponse\x12\x66\n\x10\x43ompleteTransfer\x12-.pylabrobot.liconic.storex.v1.TransferRequest\x1a#.pylabrobot.liconic.storex.v1.Empty\x12\x65\n\x0fReleaseTransfer\x12-.pylabrobot.liconic.storex.v1.TransferRequest\x1a#.pylabrobot.liconic.storex.v1.Empty\x12x\n\x11RegisterTrayPlate\x12\x36.pylabrobot.liconic.storex.v1.RegisterTrayPlateRequest\x1a+.pylabrobot.liconic.storex.v1.PlateSnapshot\x12p\n\x0eStoreTrayPlate\x12\x33.pylabrobot.liconic.storex.v1.StoreTrayPlateRequest\x1a).pylabrobot.liconic.storex.v1.StoredPlateb\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, "pylabrobot.liconic.storex.experimental_server.storex_pb2", _globals +) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._options = None + _globals["_EMPTY"]._serialized_start = 92 + _globals["_EMPTY"]._serialized_end = 99 + _globals["_PLATESNAPSHOT"]._serialized_start = 101 + _globals["_PLATESNAPSHOT"]._serialized_end = 153 + _globals["_ACTIVETRANSFER"]._serialized_start = 155 + _globals["_ACTIVETRANSFER"]._serialized_end = 212 + _globals["_ACTIVEOPERATION"]._serialized_start = 214 + _globals["_ACTIVEOPERATION"]._serialized_end = 285 + _globals["_STOREXSTATE"]._serialized_start = 288 + _globals["_STOREXSTATE"]._serialized_end = 526 + _globals["_FETCHPLATEREQUEST"]._serialized_start = 528 + _globals["_FETCHPLATEREQUEST"]._serialized_end = 589 + _globals["_CLAIMTRAYPLATEREQUEST"]._serialized_start = 591 + _globals["_CLAIMTRAYPLATEREQUEST"]._serialized_end = 635 + _globals["_CLAIMTRAYPLATERESPONSE"]._serialized_start = 637 + _globals["_CLAIMTRAYPLATERESPONSE"]._serialized_end = 742 + _globals["_TRANSFERREQUEST"]._serialized_start = 744 + _globals["_TRANSFERREQUEST"]._serialized_end = 782 + _globals["_REGISTERTRAYPLATEREQUEST"]._serialized_start = 784 + _globals["_REGISTERTRAYPLATEREQUEST"]._serialized_end = 833 + _globals["_SITEADDRESS"]._serialized_start = 835 + _globals["_SITEADDRESS"]._serialized_end = 884 + _globals["_SMALLESTFIT"]._serialized_start = 886 + _globals["_SMALLESTFIT"]._serialized_end = 899 + _globals["_RANDOMFIT"]._serialized_start = 901 + _globals["_RANDOMFIT"]._serialized_end = 912 + _globals["_STORETRAYPLATEREQUEST"]._serialized_start = 915 + _globals["_STORETRAYPLATEREQUEST"]._serialized_end = 1184 + _globals["_STOREDPLATE"]._serialized_start = 1187 + _globals["_STOREDPLATE"]._serialized_end = 1317 + _globals["_STOREXSERVICE"]._serialized_start = 1320 + _globals["_STOREXSERVICE"]._serialized_end = 2191 +# @@protoc_insertion_point(module_scope) diff --git a/pylabrobot/liconic/storex/experimental_server/storex_pb2.pyi b/pylabrobot/liconic/storex/experimental_server/storex_pb2.pyi new file mode 100644 index 00000000000..dad75d2f7b1 --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/storex_pb2.pyi @@ -0,0 +1,140 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ( + ClassVar as _ClassVar, + Mapping as _Mapping, + Optional as _Optional, + Union as _Union, +) + +DESCRIPTOR: _descriptor.FileDescriptor + +class Empty(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class PlateSnapshot(_message.Message): + __slots__ = ("name", "resource_json") + NAME_FIELD_NUMBER: _ClassVar[int] + RESOURCE_JSON_FIELD_NUMBER: _ClassVar[int] + name: str + resource_json: str + def __init__(self, name: _Optional[str] = ..., resource_json: _Optional[str] = ...) -> None: ... + +class ActiveTransfer(_message.Message): + __slots__ = ("transfer_id", "plate_name") + TRANSFER_ID_FIELD_NUMBER: _ClassVar[int] + PLATE_NAME_FIELD_NUMBER: _ClassVar[int] + transfer_id: str + plate_name: str + def __init__( + self, transfer_id: _Optional[str] = ..., plate_name: _Optional[str] = ... + ) -> None: ... + +class ActiveOperation(_message.Message): + __slots__ = ("name", "plate_name") + NAME_FIELD_NUMBER: _ClassVar[int] + PLATE_NAME_FIELD_NUMBER: _ClassVar[int] + name: str + plate_name: str + def __init__(self, name: _Optional[str] = ..., plate_name: _Optional[str] = ...) -> None: ... + +class StoreXState(_message.Message): + __slots__ = ("storex_resource_json", "active_transfer", "active_operation") + STOREX_RESOURCE_JSON_FIELD_NUMBER: _ClassVar[int] + ACTIVE_TRANSFER_FIELD_NUMBER: _ClassVar[int] + ACTIVE_OPERATION_FIELD_NUMBER: _ClassVar[int] + storex_resource_json: str + active_transfer: ActiveTransfer + active_operation: ActiveOperation + def __init__( + self, + storex_resource_json: _Optional[str] = ..., + active_transfer: _Optional[_Union[ActiveTransfer, _Mapping]] = ..., + active_operation: _Optional[_Union[ActiveOperation, _Mapping]] = ..., + ) -> None: ... + +class FetchPlateRequest(_message.Message): + __slots__ = ("plate_name", "read_barcode") + PLATE_NAME_FIELD_NUMBER: _ClassVar[int] + READ_BARCODE_FIELD_NUMBER: _ClassVar[int] + plate_name: str + read_barcode: bool + def __init__(self, plate_name: _Optional[str] = ..., read_barcode: bool = ...) -> None: ... + +class ClaimTrayPlateRequest(_message.Message): + __slots__ = ("transfer_id",) + TRANSFER_ID_FIELD_NUMBER: _ClassVar[int] + transfer_id: str + def __init__(self, transfer_id: _Optional[str] = ...) -> None: ... + +class ClaimTrayPlateResponse(_message.Message): + __slots__ = ("transfer_id", "plate") + TRANSFER_ID_FIELD_NUMBER: _ClassVar[int] + PLATE_FIELD_NUMBER: _ClassVar[int] + transfer_id: str + plate: PlateSnapshot + def __init__( + self, transfer_id: _Optional[str] = ..., plate: _Optional[_Union[PlateSnapshot, _Mapping]] = ... + ) -> None: ... + +class TransferRequest(_message.Message): + __slots__ = ("transfer_id",) + TRANSFER_ID_FIELD_NUMBER: _ClassVar[int] + transfer_id: str + def __init__(self, transfer_id: _Optional[str] = ...) -> None: ... + +class RegisterTrayPlateRequest(_message.Message): + __slots__ = ("resource_json",) + RESOURCE_JSON_FIELD_NUMBER: _ClassVar[int] + resource_json: str + def __init__(self, resource_json: _Optional[str] = ...) -> None: ... + +class SiteAddress(_message.Message): + __slots__ = ("cassette", "position") + CASSETTE_FIELD_NUMBER: _ClassVar[int] + POSITION_FIELD_NUMBER: _ClassVar[int] + cassette: int + position: int + def __init__(self, cassette: _Optional[int] = ..., position: _Optional[int] = ...) -> None: ... + +class SmallestFit(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class RandomFit(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class StoreTrayPlateRequest(_message.Message): + __slots__ = ("plate_name", "site", "smallest_fit", "random_fit", "read_barcode") + PLATE_NAME_FIELD_NUMBER: _ClassVar[int] + SITE_FIELD_NUMBER: _ClassVar[int] + SMALLEST_FIT_FIELD_NUMBER: _ClassVar[int] + RANDOM_FIT_FIELD_NUMBER: _ClassVar[int] + READ_BARCODE_FIELD_NUMBER: _ClassVar[int] + plate_name: str + site: SiteAddress + smallest_fit: SmallestFit + random_fit: RandomFit + read_barcode: bool + def __init__( + self, + plate_name: _Optional[str] = ..., + site: _Optional[_Union[SiteAddress, _Mapping]] = ..., + smallest_fit: _Optional[_Union[SmallestFit, _Mapping]] = ..., + random_fit: _Optional[_Union[RandomFit, _Mapping]] = ..., + read_barcode: bool = ..., + ) -> None: ... + +class StoredPlate(_message.Message): + __slots__ = ("plate", "site") + PLATE_FIELD_NUMBER: _ClassVar[int] + SITE_FIELD_NUMBER: _ClassVar[int] + plate: PlateSnapshot + site: SiteAddress + def __init__( + self, + plate: _Optional[_Union[PlateSnapshot, _Mapping]] = ..., + site: _Optional[_Union[SiteAddress, _Mapping]] = ..., + ) -> None: ... diff --git a/pylabrobot/liconic/storex/experimental_server/storex_pb2_grpc.py b/pylabrobot/liconic/storex/experimental_server/storex_pb2_grpc.py new file mode 100644 index 00000000000..07aba089f4d --- /dev/null +++ b/pylabrobot/liconic/storex/experimental_server/storex_pb2_grpc.py @@ -0,0 +1,403 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" + +import grpc + +from pylabrobot.liconic.storex.experimental_server import ( + storex_pb2 as pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2, +) + + +class StoreXServiceStub(object): + """The service owns the StoreX resource tree. A workcell receives a copy of a + plate only through the loading-tray transfer protocol. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.GetState = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/GetState", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreXState.FromString, + ) + self.Initialize = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/Initialize", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + ) + self.FetchPlate = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/FetchPlate", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.FetchPlateRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.FromString, + ) + self.ClaimTrayPlate = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/ClaimTrayPlate", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateResponse.FromString, + ) + self.CompleteTransfer = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/CompleteTransfer", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + ) + self.ReleaseTransfer = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/ReleaseTransfer", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + ) + self.RegisterTrayPlate = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/RegisterTrayPlate", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.RegisterTrayPlateRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.FromString, + ) + self.StoreTrayPlate = channel.unary_unary( + "/pylabrobot.liconic.storex.v1.StoreXService/StoreTrayPlate", + request_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreTrayPlateRequest.SerializeToString, + response_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoredPlate.FromString, + ) + + +class StoreXServiceServicer(object): + """The service owns the StoreX resource tree. A workcell receives a copy of a + plate only through the loading-tray transfer protocol. + """ + + def GetState(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def Initialize(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def FetchPlate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ClaimTrayPlate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def CompleteTransfer(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ReleaseTransfer(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def RegisterTrayPlate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def StoreTrayPlate(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_StoreXServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + "GetState": grpc.unary_unary_rpc_method_handler( + servicer.GetState, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreXState.SerializeToString, + ), + "Initialize": grpc.unary_unary_rpc_method_handler( + servicer.Initialize, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + ), + "FetchPlate": grpc.unary_unary_rpc_method_handler( + servicer.FetchPlate, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.FetchPlateRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.SerializeToString, + ), + "ClaimTrayPlate": grpc.unary_unary_rpc_method_handler( + servicer.ClaimTrayPlate, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateResponse.SerializeToString, + ), + "CompleteTransfer": grpc.unary_unary_rpc_method_handler( + servicer.CompleteTransfer, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + ), + "ReleaseTransfer": grpc.unary_unary_rpc_method_handler( + servicer.ReleaseTransfer, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + ), + "RegisterTrayPlate": grpc.unary_unary_rpc_method_handler( + servicer.RegisterTrayPlate, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.RegisterTrayPlateRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.SerializeToString, + ), + "StoreTrayPlate": grpc.unary_unary_rpc_method_handler( + servicer.StoreTrayPlate, + request_deserializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreTrayPlateRequest.FromString, + response_serializer=pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoredPlate.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + "pylabrobot.liconic.storex.v1.StoreXService", rpc_method_handlers + ) + server.add_generic_rpc_handlers((generic_handler,)) + + +# This class is part of an EXPERIMENTAL API. +class StoreXService(object): + """The service owns the StoreX resource tree. A workcell receives a copy of a + plate only through the loading-tray transfer protocol. + """ + + @staticmethod + def GetState( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/GetState", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreXState.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def Initialize( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/Initialize", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def FetchPlate( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/FetchPlate", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.FetchPlateRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def ClaimTrayPlate( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/ClaimTrayPlate", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.ClaimTrayPlateResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def CompleteTransfer( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/CompleteTransfer", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def ReleaseTransfer( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/ReleaseTransfer", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.TransferRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def RegisterTrayPlate( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/RegisterTrayPlate", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.RegisterTrayPlateRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.PlateSnapshot.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def StoreTrayPlate( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/pylabrobot.liconic.storex.v1.StoreXService/StoreTrayPlate", + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoreTrayPlateRequest.SerializeToString, + pylabrobot_dot_liconic_dot_storex_dot_experimental__server_dot_storex__pb2.StoredPlate.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) diff --git a/pylabrobot/liconic/storex/racks.py b/pylabrobot/liconic/storex/racks.py new file mode 100644 index 00000000000..598b842f3e1 --- /dev/null +++ b/pylabrobot/liconic/storex/racks.py @@ -0,0 +1,362 @@ +"""Cassette resources for LiCONiC StoreX systems.""" + +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateCarrier, PlateHolder + + +def _storex_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 storex_rack_5mm_42(name: str): + """STX44. Pitch 11mm, motor steps 377.""" + return _storex_rack(name=name, site_height=5, num_sites=42, model="storex_rack_5mm_42") + + +def storex_rack_5mm_55(name: str): + """STX500 bicarousel. Pitch 11mm, motor steps 377.""" + return _storex_rack( + name=name, + site_height=5, + num_sites=55, + model="storex_rack_5mm_55", + total_height=645, + ) + + +def storex_rack_5mm_111(name: str): + """STX1000 bicarousel. Pitch 11mm, motor steps 377.""" + return _storex_rack( + name=name, + site_height=5, + num_sites=111, + model="storex_rack_5mm_111", + total_height=1210, + ) + + +def storex_rack_11mm_28(name: str): + """STX44. Pitch 17mm, motor steps 582.""" + return _storex_rack(name=name, site_height=11, num_sites=28, model="storex_rack_11mm_28") + + +def storex_rack_11mm_37(name: str): + """STX500 bicarousel. Pitch 17mm, motor steps 582.""" + return _storex_rack( + name=name, + site_height=11, + num_sites=37, + model="storex_rack_11mm_37", + total_height=645, + ) + + +def storex_rack_11mm_72(name: str): + """STX1000 bicarousel. Pitch 17mm, motor steps 582.""" + return _storex_rack( + name=name, + site_height=11, + num_sites=72, + model="storex_rack_11mm_72", + total_height=1210, + ) + + +def storex_rack_12mm_27(name: str): + """STX44. Pitch 18mm, motor steps 617.""" + return _storex_rack(name=name, site_height=12, num_sites=27, model="storex_rack_12mm_27") + + +def storex_rack_12mm_35(name: str): + """STX500 bicarousel. Pitch 18mm, motor steps 617.""" + return _storex_rack( + name=name, + site_height=12, + num_sites=35, + model="storex_rack_12mm_35", + total_height=645, + ) + + +def storex_rack_12mm_68(name: str): + """STX1000 bicarousel. Pitch 18mm, motor steps 617.""" + return _storex_rack( + name=name, + site_height=12, + num_sites=68, + model="storex_rack_12mm_68", + total_height=1210, + ) + + +def storex_rack_17mm_22(name: str): + """STX44. Pitch 23mm, motor steps 788.""" + return _storex_rack(name=name, site_height=17, num_sites=22, model="storex_rack_17mm_22") + + +def storex_rack_17mm_28(name: str): + """STX500 bicarousel. Pitch 23mm, motor steps 788.""" + return _storex_rack( + name=name, + site_height=17, + num_sites=28, + model="storex_rack_17mm_28", + total_height=645, + ) + + +def storex_rack_17mm_53(name: str): + """STX1000 bicarousel. Pitch 23mm, motor steps 788.""" + return _storex_rack( + name=name, + site_height=17, + num_sites=53, + model="storex_rack_17mm_53", + total_height=1210, + ) + + +def storex_rack_22mm_17(name: str): + """STX44. Pitch 28mm, motor steps 959.""" + return _storex_rack(name=name, site_height=22, num_sites=17, model="storex_rack_22mm_17") + + +def storex_rack_22mm_23(name: str): + """STX500 bicarousel. Pitch 28mm, motor steps 959.""" + return _storex_rack( + name=name, + site_height=22, + num_sites=23, + model="storex_rack_22mm_23", + total_height=645, + ) + + +def storex_rack_22mm_43(name: str): + """STX1000 bicarousel. Pitch 28mm, motor steps 959.""" + return _storex_rack( + name=name, + site_height=22, + num_sites=43, + model="storex_rack_22mm_43", + total_height=1210, + ) + + +def storex_rack_23mm_17(name: str): + """STX44. Pitch 29mm, motor steps 994.""" + return _storex_rack(name=name, site_height=23, num_sites=17, model="storex_rack_23mm_17") + + +def storex_rack_23mm_22(name: str): + """STX500 bicarousel. Pitch 29mm, motor steps 994.""" + return _storex_rack( + name=name, + site_height=23, + num_sites=22, + model="storex_rack_23mm_22", + total_height=645, + ) + + +def storex_rack_23mm_42(name: str): + """STX1000 bicarousel. Pitch 29mm, motor steps 994.""" + return _storex_rack( + name=name, + site_height=23, + num_sites=42, + model="storex_rack_23mm_42", + total_height=1210, + ) + + +def storex_rack_24mm_17(name: str): + """STX44. Pitch 30mm, motor steps 1028.""" + return _storex_rack(name=name, site_height=24, num_sites=17, model="storex_rack_24mm_17") + + +def storex_rack_24mm_21(name: str): + """STX500 bicarousel. Pitch 30mm, motor steps 1028.""" + return _storex_rack( + name=name, + site_height=24, + num_sites=21, + model="storex_rack_24mm_21", + total_height=645, + ) + + +def storex_rack_24mm_41(name: str): + """STX1000 bicarousel. Pitch 30mm, motor steps 1028.""" + return _storex_rack( + name=name, + site_height=24, + num_sites=41, + model="storex_rack_24mm_41", + total_height=1210, + ) + + +def storex_rack_27mm_15(name: str): + """STX44. Pitch 33mm, motor steps 1131.""" + return _storex_rack(name=name, site_height=27, num_sites=15, model="storex_rack_27mm_15") + + +def storex_rack_27mm_19(name: str): + """STX500 bicarousel. Pitch 33mm, motor steps 1131.""" + return _storex_rack( + name=name, + site_height=27, + num_sites=19, + model="storex_rack_27mm_19", + total_height=645, + ) + + +def storex_rack_27mm_37(name: str): + """STX1000 bicarousel. Pitch 33mm, motor steps 1131.""" + return _storex_rack( + name=name, + site_height=27, + num_sites=37, + model="storex_rack_27mm_37", + total_height=1210, + ) + + +def storex_rack_44mm_10(name: str): + """STX44. Pitch 50mm, motor steps 1713.""" + return _storex_rack(name=name, site_height=44, num_sites=10, model="storex_rack_44mm_10") + + +def storex_rack_44mm_13(name: str): + """STX500 bicarousel. Pitch 50mm, motor steps 1713.""" + return _storex_rack( + name=name, + site_height=44, + num_sites=13, + model="storex_rack_44mm_13", + total_height=645, + ) + + +def storex_rack_44mm_25(name: str): + """STX1000 bicarousel. Pitch 50mm, motor steps 1713.""" + return _storex_rack( + name=name, + site_height=44, + num_sites=25, + model="storex_rack_44mm_25", + total_height=1210, + ) + + +def storex_rack_53mm_8(name: str): + """STX44. Pitch 59mm, motor steps 2021.""" + return _storex_rack(name=name, site_height=53, num_sites=8, model="storex_rack_53mm_8") + + +def storex_rack_53mm_10(name: str): + """STX500 bicarousel. Pitch 59mm, motor steps 2021.""" + return _storex_rack( + name=name, + site_height=53, + num_sites=10, + model="storex_rack_53mm_10", + total_height=645, + ) + + +def storex_rack_53mm_21(name: str): + """STX1000 bicarousel. Pitch 59mm, motor steps 2021.""" + return _storex_rack( + name=name, + site_height=53, + num_sites=21, + model="storex_rack_53mm_21", + total_height=1210, + ) + + +def storex_rack_66mm_7(name: str): + """STX44. Pitch 72mm, motor steps 2467.""" + return _storex_rack(name=name, site_height=66, num_sites=7, model="storex_rack_66mm_7") + + +def storex_rack_66mm_8(name: str): + """STX500 bicarousel. Pitch 72mm, motor steps 2467.""" + return _storex_rack( + name=name, + site_height=66, + num_sites=8, + model="storex_rack_66mm_8", + total_height=645, + ) + + +def storex_rack_66mm_17(name: str): + """STX1000 bicarousel. Pitch 72mm, motor steps 2467.""" + return _storex_rack( + name=name, + site_height=66, + num_sites=17, + model="storex_rack_66mm_17", + total_height=1210, + ) + + +def storex_rack_104mm_4(name: str): + """STX44. Pitch 110mm, motor steps 3563.""" + return _storex_rack(name=name, site_height=104, num_sites=4, model="storex_rack_104mm_4") + + +def storex_rack_104mm_5(name: str): + """STX500 bicarousel. Pitch 110mm, motor steps 3563.""" + return _storex_rack( + name=name, + site_height=104, + num_sites=5, + model="storex_rack_104mm_5", + total_height=645, + ) + + +def storex_rack_104mm_11(name: str): + """STX1000 bicarousel. Pitch 110mm, motor steps 3563.""" + return _storex_rack( + name=name, + site_height=104, + num_sites=11, + model="storex_rack_104mm_11", + total_height=1210, + ) diff --git a/pylabrobot/liconic/storex/storex.py b/pylabrobot/liconic/storex/storex.py new file mode 100644 index 00000000000..b6810994f9d --- /dev/null +++ b/pylabrobot/liconic/storex/storex.py @@ -0,0 +1,628 @@ +"""LiCONiC StoreX driver.""" + +from __future__ import annotations + +import asyncio +import logging +import random +import re +import time +from typing import List, Literal, Optional, Protocol, Sequence, Tuple, Union, cast + +from pylabrobot.io.serial import Serial +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) +from pylabrobot.resources.barcode import Barcode +from pylabrobot.serializer import deserialize, serialize + +from .constants import STOREX_MODELS, ControllerError, HandlingError, StoreXModel +from .errors import controller_error_map, handler_error_map + +logger = logging.getLogger(__name__) + + +STOREX_SITE_HEIGHT_TO_STEPS = { + 5: 377, + 11: 582, + 12: 617, + 17: 788, + 22: 959, + 23: 994, + 24: 1028, + 27: 1131, + 44: 1713, + 53: 2021, + 66: 2467, + 104: 3563, +} + +StorageSite = Union[PlateHolder, Literal["random", "smallest"]] + + +class BarcodeScanner(Protocol): + """Interface required from an internal barcode scanner.""" + + async def setup(self) -> None: ... + + async def stop(self) -> None: ... + + async def scan_barcode(self, read_time: Optional[float] = None) -> Optional[Barcode]: ... + + +class NoFreeSiteError(Exception): + """Raised when none of the configured rack sites can hold a plate.""" + + +class StoreX(Resource): + """LiCONiC StoreX automated incubator and storage system (STX line). + + The device contains one or more cassettes represented by + :class:`~pylabrobot.resources.PlateCarrier` objects and a loading tray. Plate + transfer, climate, shaking, sensor, and optional barcode operations are sent + to the controller over its ASCII PLC protocol. + + Serial communication uses 9600 baud, 8 data bits, even parity, 1 stop bit, + RTS/CTS, and a ``\\r`` command terminator. + + """ + + serial_message_encoding = "ascii" + + def __init__( + self, + name: str, + model: StoreXModel, + port: str, + racks: Sequence[PlateCarrier], + loading_tray_location: Coordinate, + has_shaker: bool = False, + barcode_scanner: Optional[BarcodeScanner] = None, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + rotation: Optional[Rotation] = None, + ) -> None: + if model not in STOREX_MODELS: + raise ValueError(f"Unsupported StoreX model: {model!r}") + + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category="incubator", + model=model, + ) + self.storex_model = model + self.has_shaker = has_shaker + self.barcode_scanner = barcode_scanner + self._racks = list(racks) + + self.loading_tray = PlateHolder( + name=f"{name}_tray", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.io = Serial( + human_readable_device_name=f"LiCONiC StoreX {model}", + port=port, + baudrate=9600, + bytesize=8, + parity="E", + stopbits=1, + write_timeout=1, + timeout=1, + rtscts=True, + ) + + @property + def racks(self) -> List[PlateCarrier]: + """The cassettes in controller order.""" + return self._racks + + @property + def climate_suffix(self) -> str: + return self.storex_model.rsplit("_", 1)[-1] + + @property + def supports_temperature_control(self) -> bool: + return self.climate_suffix != "NC" + + @property + def supports_humidity_control(self) -> bool: + return self.climate_suffix in {"DC2", "DR2", "AR", "DH"} + + @property + def supports_active_cooling(self) -> bool: + return self.climate_suffix in {"HC", "HR", "DF"} + + def serialize(self) -> dict: + return { + **Resource.serialize(self), + "port": self.io.port, + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + "has_shaker": self.has_shaker, + } + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False) -> "StoreX": + return cls( + name=data["name"], + model=cast(StoreXModel, data["model"]), + port=data["port"], + racks=[ + cast( + PlateCarrier, + PlateCarrier.deserialize(rack, allow_marshal=allow_marshal), + ) + for rack in data["racks"] + ], + loading_tray_location=cast( + Coordinate, + deserialize(data["loading_tray_location"], allow_marshal=allow_marshal), + ), + has_shaker=data.get("has_shaker", False), + size_x=data["size_x"], + size_y=data["size_y"], + size_z=data["size_z"], + rotation=cast( + Optional[Rotation], + deserialize(data.get("rotation"), allow_marshal=allow_marshal), + ), + ) + + async def setup(self) -> None: + await self.io.setup() + try: + await self.io.send_break(duration=0.2) + 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.monotonic() + 1.0 + while time.monotonic() < deadline: + if (await self.io.readline()).strip() == b"CC": + break + else: + raise TimeoutError("No CC response from StoreX PLC within 1.0 seconds") + + await self.io.write(b"ST 1801\r") + response = await self.io.readline() + if response.strip() != b"OK": + raise RuntimeError(f"Unexpected reply to ST 1801: {response!r}") + + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + await self.io.write(b"RD 1915\r") + if (await self.io.readline()).strip() == b"1": + break + await asyncio.sleep(0.2) + else: + raise TimeoutError("PLC did not signal ready within 15.0 seconds") + + if self.barcode_scanner is not None: + await self.barcode_scanner.setup() + except Exception: + await self.io.stop() + raise + + logger.info("[StoreX %s] connected: model=%s", self.io.port, self.storex_model) + + async def stop(self) -> None: + try: + await self.io.stop() + finally: + if self.barcode_scanner is not None: + await self.barcode_scanner.stop() + logger.info("[StoreX %s] disconnected", self.io.port) + + async def _send_command(self, command: str) -> str: + command = command.strip() + logger.debug("[StoreX %s] send: %s", self.io.port, command) + await self.io.write(f"{command}\r".encode(self.serial_message_encoding)) + response = (await self.io.read(128)).decode(self.serial_message_encoding).strip() + if not response: + raise RuntimeError(f"No response from StoreX PLC for command {command!r}") + if response.startswith("E"): + logger.error("[StoreX %s] command %s failed: %s", self.io.port, command, response) + try: + error = ControllerError(response) + except ValueError: + raise RuntimeError(f"Unknown error {response} when sending command {command}") + error_type, message = controller_error_map[error] + raise error_type(message) + logger.debug("[StoreX %s] recv: %s", self.io.port, response) + return response + + async def _wait_ready(self, timeout: float = 60.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if await self._send_command("RD 1915") == "1": + return + await asyncio.sleep(0.1) + + if await self._send_command("RD 1814") == "1": + response = await self._send_command("RD DM200") + try: + error = HandlingError(response) + except ValueError: + raise RuntimeError( + f"StoreX handler is in an unknown error state with memory showing {response}" + ) + error_type, message = handler_error_map[error] + raise error_type(message) + raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") + + def _site_to_address(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + if not isinstance(rack, PlateCarrier) or rack not in self._racks: + raise ValueError(f"Site {site.name!r} is not in a configured StoreX rack") + try: + site_index = next(index for index, candidate in rack.sites.items() if candidate is site) + except StopIteration: + raise ValueError(f"Site {site.name!r} is not indexed by its parent rack") + return self._racks.index(rack) + 1, site_index + 1 + + def _carrier_to_steps_and_positions(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + if not isinstance(rack, PlateCarrier) or rack not in self._racks: + raise ValueError(f"Site {site.name!r} is not in a configured StoreX rack") + if rack.model is None or not rack.model.startswith("storex_rack_"): + raise ValueError(f"Plate carrier model {rack.model!r} is not compatible with StoreX") + match = re.fullmatch(r"storex_rack_(\d+)mm_(\d+)", rack.model) + if match is None: + raise ValueError(f"Could not parse StoreX plate carrier model: {rack.model}") + site_height, num_positions = (int(value) for value in match.groups()) + try: + steps = STOREX_SITE_HEIGHT_TO_STEPS[site_height] + except KeyError: + raise ValueError(f"Unknown StoreX site height: {site_height} mm") + if num_positions != len(rack.sites): + raise ValueError( + f"Rack model declares {num_positions} positions but contains {len(rack.sites)} sites" + ) + return steps, num_positions + + async def _configure_site(self, site: PlateHolder) -> Tuple[int, int]: + cassette, position = self._site_to_address(site) + steps, num_positions = self._carrier_to_steps_and_positions(site) + await self._send_command(f"WR DM0 {cassette}") + await self._send_command(f"WR DM23 {steps}") + await self._send_command(f"WR DM25 {num_positions}") + await self._send_command(f"WR DM5 {position}") + return cassette, position + + def get_num_free_sites(self) -> int: + return sum(len(rack.get_free_sites()) for rack in self._racks) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name!r} not found in StoreX {self.name!r}") + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + plate_height = plate.get_size_z() + 3 if plate.has_lid() else plate.get_size_z() + sites = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= plate_height + ] + if not sites: + raise NoFreeSiteError(f"No free site found in StoreX {self.name!r} for plate {plate.name!r}") + return sorted(sites, key=lambda site: site.get_size_z()) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site_for_plate(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + async def initialize(self) -> None: + """Home and activate the plate handler.""" + await self._send_command("ST 1900") + await self._send_command("ST 1801") + await self._wait_ready() + + async def open_door(self) -> None: + await self._send_command("ST 1901") + await self._wait_ready() + + async def close_door(self) -> None: + await self._send_command("ST 1902") + await self._wait_ready() + + async def fetch_plate_to_loading_tray(self, plate_name: str, read_barcode: bool = False) -> Plate: + """Move a named stored plate to the loading tray.""" + if self.loading_tray.resource is not None: + raise ValueError(f"StoreX loading tray {self.loading_tray.name!r} is occupied") + site = self.get_site_by_plate_name(plate_name) + plate = cast(Plate, site.resource) + cassette, position = await self._configure_site(site) + if read_barcode: + barcode = await self._read_barcode_inline(cassette, position) + if barcode is not None: + plate.barcode = barcode + await self._send_command("ST 1905") + await self._wait_ready() + await self._send_command("ST 1903") + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + async def take_in_plate( + self, site: StorageSite = "smallest", read_barcode: bool = False + ) -> Plate: + """Move the plate on the loading tray into a rack site.""" + plate = self.loading_tray.resource + if not isinstance(plate, Plate): + raise ResourceNotFoundError(f"No plate on StoreX loading tray {self.loading_tray.name!r}") + if site == "smallest": + destination = self.find_smallest_site_for_plate(plate) + elif site == "random": + destination = self.find_random_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name!r} is not available for plate {plate.name!r}") + destination = site + else: + raise ValueError(f"Invalid StoreX storage site: {site!r}") + + cassette, position = await self._configure_site(destination) + await self._send_command("ST 1904") + await self._wait_ready() + if read_barcode: + barcode = await self._read_barcode_inline(cassette, position) + if barcode is not None: + plate.barcode = barcode + await self._send_command("ST 1903") + plate.unassign() + destination.assign_child_resource(plate) + return plate + + async def move_plate( + self, plate_name: str, destination: PlateHolder, read_barcode: bool = False + ) -> Plate: + """Move a named plate between two internal rack sites.""" + if destination.resource is not None: + raise ValueError(f"Destination site {destination.name!r} is occupied") + origin = self.get_site_by_plate_name(plate_name) + plate = cast(Plate, origin.resource) + self._site_to_address(destination) + self._carrier_to_steps_and_positions(destination) + + origin_cassette, origin_position = await self._configure_site(origin) + if read_barcode: + barcode = await self._read_barcode_inline(origin_cassette, origin_position) + if barcode is not None: + plate.barcode = barcode + await self._send_command("ST 1908") + await self._wait_ready() + + await self._configure_site(destination) + await self._send_command("ST 1909") + await self._wait_ready() + await self._send_command("ST 1903") + plate.unassign() + destination.assign_child_resource(plate) + return plate + + def _require_barcode_scanner(self) -> BarcodeScanner: + if self.barcode_scanner is None: + raise RuntimeError("No barcode scanner is configured for this StoreX") + return self.barcode_scanner + + async def _read_barcode_inline(self, cassette: int, plate_position: int) -> Optional[Barcode]: + scanner = self._require_barcode_scanner() + await self._send_command("ST 1910") + await self._wait_ready() + try: + barcode = await scanner.scan_barcode() + finally: + response = await self._send_command("RS 1910") + if response != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") + await self._wait_ready() + logger.info( + "[StoreX %s] barcode: cassette=%d position=%d value=%s", + self.io.port, + cassette, + plate_position, + barcode, + ) + return barcode + + async def scan_barcode(self, site: PlateHolder) -> Optional[Barcode]: + """Scan the plate in an internal rack site.""" + cassette, position = await self._configure_site(site) + barcode = await self._read_barcode_inline(cassette, position) + await self._send_command("ST 1903") + if barcode is not None and isinstance(site.resource, Plate): + site.resource.barcode = barcode + return barcode + + async def set_temperature(self, temperature: float) -> None: + if not self.supports_temperature_control: + raise NotImplementedError("Temperature control is not supported on this model") + await self._send_command(f"WR DM890 {int(temperature * 10):05d}") + await self._wait_ready() + + async def request_current_temperature(self) -> float: + if not self.supports_temperature_control: + raise NotImplementedError("Temperature control is not supported on this model") + response = await self._send_command("RD DM982") + try: + return int(response) / 10.0 + except ValueError: + raise RuntimeError(f"Invalid temperature value received from StoreX: {response!r}") + + async def request_target_temperature(self) -> float: + if not self.supports_temperature_control: + raise NotImplementedError("Temperature control is not supported on this model") + response = await self._send_command("RD DM890") + try: + return int(response) / 10.0 + except ValueError: + raise RuntimeError(f"Invalid target temperature received from StoreX: {response!r}") + + async def set_humidity(self, humidity: float) -> None: + if not self.supports_humidity_control: + raise NotImplementedError("Independent humidity control is not supported on this model") + self._validate_fraction("humidity", humidity) + await self._send_command(f"WR DM893 {int(humidity * 1000):05d}") + await self._wait_ready() + + async def request_current_humidity(self) -> float: + if not self.supports_humidity_control: + raise NotImplementedError("Independent humidity control is not supported on this model") + return await self._request_fraction("RD DM983", 1000, "humidity") + + async def request_target_humidity(self) -> float: + if not self.supports_humidity_control: + raise NotImplementedError("Independent humidity control is not supported on this model") + return await self._request_fraction("RD DM893", 1000, "target humidity") + + async def set_co2_level(self, co2_level: float) -> None: + self._validate_fraction("CO2 level", co2_level) + await self._send_command(f"WR DM894 {int(co2_level * 10000):05d}") + await self._wait_ready() + + async def request_co2_level(self) -> float: + return await self._request_fraction("RD DM984", 10000, "CO2 level") + + async def request_target_co2_level(self) -> float: + return await self._request_fraction("RD DM894", 10000, "target CO2 level") + + async def set_n2_level(self, n2_level: float) -> None: + self._validate_fraction("N2 level", n2_level) + await self._send_command(f"WR DM895 {int(n2_level * 10000):05d}") + await self._wait_ready() + + async def request_n2_level(self) -> float: + return await self._request_fraction("RD DM985", 10000, "N2 level") + + async def request_target_n2_level(self) -> float: + return await self._request_fraction("RD DM895", 10000, "target N2 level") + + @staticmethod + def _validate_fraction(name: str, value: float) -> None: + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be between 0.0 and 1.0") + + async def _request_fraction(self, command: str, scale: int, name: str) -> float: + response = await self._send_command(command) + try: + return int(response) / scale + except ValueError: + raise RuntimeError(f"Invalid {name} received from StoreX: {response!r}") + + def _require_shaker(self) -> None: + if not self.has_shaker: + raise NotImplementedError("This StoreX was not configured with a shaker") + + async def request_shaker_speed(self) -> float: + self._require_shaker() + response = await self._send_command("RD DM39") + try: + return int(response) / 10.0 + except ValueError: + raise RuntimeError(f"Invalid shaker speed received from StoreX: {response!r}") + + async def start_shaking(self, frequency: float) -> None: + self._require_shaker() + if not 1.0 <= frequency <= 50.0: + raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") + await self._send_command(f"WR DM39 {int(frequency * 10):05d}") + await self._send_command("ST 1913") + await self._wait_ready() + + async def stop_shaking(self) -> None: + self._require_shaker() + await self._send_command("RS 1913") + await self._wait_ready() + + async def shake(self, frequency: float, duration: float) -> None: + if duration < 0: + raise ValueError("Shaking duration cannot be negative") + await self.start_shaking(frequency) + try: + await asyncio.sleep(duration) + finally: + await self.stop_shaking() + + async def request_swap_station_is_home(self) -> bool: + response = await self._send_command("RD 1912") + if response not in {"0", "1"}: + raise RuntimeError(f"Unexpected swap station response: {response!r}") + return response == "0" + + async def move_swap_station_home(self) -> None: + if not await self.request_swap_station_is_home(): + await self._send_command("RS 1912") + + async def move_swap_station_swapped(self) -> None: + if await self.request_swap_station_is_home(): + await self._send_command("ST 1912") + + async def request_shovel_sensor(self) -> bool: + await self._send_command("ST 1911") + await asyncio.sleep(0.1) + return await self._request_binary_sensor("RD 1812", "shovel") + + async def request_transfer_sensor(self) -> bool: + return await self._request_binary_sensor("RD 1813", "transfer station") + + async def request_second_transfer_sensor(self) -> bool: + return await self._request_binary_sensor("RD 1807", "second transfer station") + + async def _request_binary_sensor(self, command: str, name: str) -> bool: + response = await self._send_command(command) + if response == "1": + return True + if response == "0": + return False + raise RuntimeError(f"Unexpected {name} sensor response: {response!r}") + + def summary(self) -> str: + """Return a compact table of the plates in each cassette.""" + headers = [f"Rack {index + 1}" for index in range(len(self._racks))] + columns = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + widths = [ + max(len(str(value)) for value in [headers[index], *columns[index]]) + for index in range(len(headers)) + ] + + def separator() -> str: + return "+" + "+".join("-" * (width + 2) for width in widths) + "+" + + def row(values: Sequence[str]) -> str: + return ( + "| " + " | ".join(value.ljust(widths[index]) for index, value in enumerate(values)) + " |" + ) + + lines = [separator(), row(headers), separator()] + lines.extend(row(values) for values in zip(*columns)) + lines.append(separator()) + return "\n".join(lines) diff --git a/pylabrobot/liconic/storex/storex_tests.py b/pylabrobot/liconic/storex/storex_tests.py new file mode 100644 index 00000000000..04c177b7017 --- /dev/null +++ b/pylabrobot/liconic/storex/storex_tests.py @@ -0,0 +1,344 @@ +# mypy: disable-error-code="assignment,attr-defined,method-assign" + +import unittest +from typing import Any, List, Optional, cast +from unittest.mock import AsyncMock + +from pylabrobot.liconic.storex import STOREX_SITE_HEIGHT_TO_STEPS, StoreX +from pylabrobot.liconic.storex.constants import StoreXModel +from pylabrobot.liconic.storex.errors import StoreXControllerCommandError +from pylabrobot.liconic.storex.racks import ( + storex_rack_5mm_42, + storex_rack_17mm_22, + storex_rack_44mm_10, +) +from pylabrobot.resources import Coordinate, Plate, PlateCarrier, PlateHolder, Rotation +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.corning.plates import cor_96_wellplate_360uL_Fb + + +def make_storex( + model: StoreXModel = "STX44_IC", + racks: Optional[List[PlateCarrier]] = None, + has_shaker: bool = False, + barcode_scanner: Any = None, +) -> StoreX: + return StoreX( + name="incubator", + model=model, + port="/dev/null", + racks=racks if racks is not None else [storex_rack_17mm_22("rack_1")], + loading_tray_location=Coordinate.zero(), + has_shaker=has_shaker, + barcode_scanner=barcode_scanner, + ) + + +def make_plate(name: str = "plate") -> Plate: + return cor_96_wellplate_360uL_Fb(name=name) + + +class TestModelsAndRacks(unittest.TestCase): + def test_invalid_model(self) -> None: + with self.assertRaises(ValueError): + make_storex(model=cast(StoreXModel, "STX42_IC")) + + def test_model_capabilities(self) -> None: + self.assertFalse(make_storex("STX44_NC").supports_temperature_control) + self.assertTrue(make_storex("STX44_HC").supports_active_cooling) + self.assertTrue(make_storex("STX44_AR").supports_humidity_control) + self.assertFalse(make_storex("STX44_IC").supports_humidity_control) + + def test_step_sizes_match_documented_formula(self) -> None: + for site_height, claimed_steps in STOREX_SITE_HEIGHT_TO_STEPS.items(): + if site_height != 104: + self.assertEqual(claimed_steps, round((site_height + 6) * 1713 / 50)) + self.assertEqual(STOREX_SITE_HEIGHT_TO_STEPS[104], 3563) + + def test_rack_construction(self) -> None: + rack = storex_rack_17mm_22("rack") + self.assertEqual(len(rack.sites), 22) + self.assertEqual(rack.model, "storex_rack_17mm_22") + self.assertEqual(rack.sites[0].get_size_z(), 17) + self.assertEqual(rack.get_size_z(), 505) + + def test_rack_addressing(self) -> None: + racks = [storex_rack_17mm_22("rack_1"), storex_rack_17mm_22("rack_2")] + storex = make_storex(racks=racks) + self.assertEqual(storex._site_to_address(racks[0].sites[0]), (1, 1)) + self.assertEqual(storex._site_to_address(racks[1].sites[21]), (2, 22)) + self.assertEqual(storex._carrier_to_steps_and_positions(racks[0].sites[0]), (788, 22)) + + def test_other_rack_steps(self) -> None: + racks = [storex_rack_5mm_42("short"), storex_rack_44mm_10("tall")] + storex = make_storex(racks=racks) + self.assertEqual(storex._carrier_to_steps_and_positions(racks[0].sites[0]), (377, 42)) + self.assertEqual(storex._carrier_to_steps_and_positions(racks[1].sites[0]), (1713, 10)) + + def test_rejects_non_storex_rack(self) -> None: + rack = PlateCarrier( + name="rack", + size_x=100, + size_y=100, + size_z=500, + sites={0: PlateHolder("site", 127, 85, 20, pedestal_size_z=0).at(Coordinate.zero())}, + model="other_rack_17mm_22", + ) + storex = make_storex(racks=[rack]) + with self.assertRaises(ValueError): + storex._carrier_to_steps_and_positions(rack.sites[0]) + + def test_serialization_round_trip(self) -> None: + rack = storex_rack_17mm_22("rack") + rack.sites[0].assign_child_resource(make_plate("stored_plate")) + storex = StoreX( + name="incubator", + model="STX44_IC", + port="/dev/ttyUSB0", + racks=[rack], + loading_tray_location=Coordinate(1, 2, 3), + has_shaker=True, + size_x=100, + size_y=200, + size_z=300, + rotation=Rotation(x=0, y=0, z=90), + ) + + restored = StoreX.deserialize(storex.serialize()) + + self.assertIsInstance(restored, StoreX) + restored = cast(StoreX, restored) + self.assertEqual(restored.name, storex.name) + self.assertEqual(restored.storex_model, storex.storex_model) + self.assertEqual(restored.io.port, storex.io.port) + self.assertEqual(restored.racks, storex.racks) + self.assertEqual(restored.loading_tray.location, storex.loading_tray.location) + self.assertEqual(restored.has_shaker, storex.has_shaker) + self.assertEqual(restored.get_size_x(), storex.get_size_x()) + self.assertEqual(restored.get_size_y(), storex.get_size_y()) + self.assertEqual(restored.get_size_z(), storex.get_size_z()) + self.assertEqual(restored.rotation.serialize(), storex.rotation.serialize()) + self.assertIs(restored.racks[0].parent, restored) + self.assertIs(restored.loading_tray.parent, restored) + restored_plate = restored.racks[0].sites[0].resource + self.assertIsNotNone(restored_plate) + self.assertEqual(cast(Plate, restored_plate).name, "stored_plate") + + +class TestLifecycle(unittest.IsolatedAsyncioTestCase): + async def test_setup_handshake_and_scanner(self) -> None: + scanner = AsyncMock() + storex = make_storex(barcode_scanner=scanner) + storex.io = AsyncMock() + storex.io.port = "/dev/null" + storex.io.readline = AsyncMock(side_effect=[b"CC\r\n", b"OK\r\n", b"1\r\n"]) + + await storex.setup() + + self.assertEqual( + [call.args[0] for call in storex.io.write.await_args_list], + [b"CR\r", b"ST 1801\r", b"RD 1915\r"], + ) + scanner.setup.assert_awaited_once() + + async def test_setup_closes_io_on_bad_activation_reply(self) -> None: + storex = make_storex() + storex.io = AsyncMock() + storex.io.readline = AsyncMock(side_effect=[b"CC\r\n", b"NO\r\n"]) + + with self.assertRaises(RuntimeError): + await storex.setup() + + storex.io.stop.assert_awaited_once() + + async def test_stop_closes_scanner(self) -> None: + scanner = AsyncMock() + storex = make_storex(barcode_scanner=scanner) + storex.io = AsyncMock() + storex.io.port = "/dev/null" + await storex.stop() + storex.io.stop.assert_awaited_once() + scanner.stop.assert_awaited_once() + + +class TestValueConversions(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.storex = make_storex("STX44_AR", has_shaker=True) + self.storex._send_command = AsyncMock(return_value="OK") + self.storex._wait_ready = AsyncMock() + + async def test_temperature(self) -> None: + await self.storex.set_temperature(37.5) + self.storex._send_command.assert_any_call("WR DM890 00375") + self.storex._send_command = AsyncMock(return_value="370") + self.assertEqual(await self.storex.request_current_temperature(), 37.0) + + async def test_humidity(self) -> None: + await self.storex.set_humidity(0.9) + self.storex._send_command.assert_any_call("WR DM893 00900") + self.storex._send_command = AsyncMock(return_value="900") + self.assertEqual(await self.storex.request_current_humidity(), 0.9) + + async def test_gases(self) -> None: + await self.storex.set_co2_level(0.05) + self.storex._send_command.assert_any_call("WR DM894 00500") + await self.storex.set_n2_level(0.9) + self.storex._send_command.assert_any_call("WR DM895 09000") + + async def test_fraction_range(self) -> None: + with self.assertRaises(ValueError): + await self.storex.set_humidity(1.1) + with self.assertRaises(ValueError): + await self.storex.set_co2_level(-0.1) + + async def test_shaking(self) -> None: + await self.storex.start_shaking(25.0) + self.storex._send_command.assert_any_call("WR DM39 00250") + self.storex._send_command.assert_any_call("ST 1913") + await self.storex.stop_shaking() + self.storex._send_command.assert_any_call("RS 1913") + + async def test_rejects_missing_features(self) -> None: + no_climate = make_storex("STX44_NC") + with self.assertRaises(NotImplementedError): + await no_climate.set_temperature(37.0) + with self.assertRaises(NotImplementedError): + await no_climate.request_current_temperature() + with self.assertRaises(NotImplementedError): + await no_climate.set_humidity(0.5) + with self.assertRaises(NotImplementedError): + await make_storex().start_shaking(10.0) + + +class TestStorageOperations(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.rack = storex_rack_17mm_22("rack") + self.storex = make_storex(racks=[self.rack]) + self.storex._send_command = AsyncMock(return_value="OK") + self.storex._wait_ready = AsyncMock() + + async def test_fetch_moves_resource_to_tray(self) -> None: + plate = make_plate() + self.rack.sites[0].assign_child_resource(plate) + + result = await self.storex.fetch_plate_to_loading_tray("plate") + + self.assertIs(result, plate) + self.assertIsNone(self.rack.sites[0].resource) + self.assertIs(self.storex.loading_tray.resource, plate) + self.storex._send_command.assert_any_call("ST 1905") + self.storex._send_command.assert_any_call("ST 1903") + + async def test_take_in_moves_resource_to_selected_site(self) -> None: + plate = make_plate() + self.storex.loading_tray.assign_child_resource(plate) + + result = await self.storex.take_in_plate(self.rack.sites[2]) + + self.assertIs(result, plate) + self.assertIsNone(self.storex.loading_tray.resource) + self.assertIs(self.rack.sites[2].resource, plate) + self.storex._send_command.assert_any_call("ST 1904") + + async def test_move_plate_updates_internal_site(self) -> None: + plate = make_plate() + self.rack.sites[0].assign_child_resource(plate) + + await self.storex.move_plate("plate", self.rack.sites[1]) + + self.assertIsNone(self.rack.sites[0].resource) + self.assertIs(self.rack.sites[1].resource, plate) + self.storex._send_command.assert_any_call("ST 1908") + self.storex._send_command.assert_any_call("ST 1909") + + async def test_move_plate_validates_destination_before_pick(self) -> None: + self.rack.sites[0].assign_child_resource(make_plate()) + other_rack = storex_rack_17mm_22("other") + with self.assertRaises(ValueError): + await self.storex.move_plate("plate", other_rack.sites[0]) + self.storex._send_command.assert_not_awaited() + + async def test_fetch_refuses_occupied_tray_before_commands(self) -> None: + self.rack.sites[0].assign_child_resource(make_plate("stored")) + self.storex.loading_tray.assign_child_resource(make_plate("tray")) + with self.assertRaises(ValueError): + await self.storex.fetch_plate_to_loading_tray("stored") + self.storex._send_command.assert_not_awaited() + + async def test_barcode_scan_waits_and_resets_shovel(self) -> None: + barcode = Barcode(data="ABC", symbology="unknown", position_on_resource="front") + scanner = AsyncMock() + scanner.scan_barcode = AsyncMock(return_value=barcode) + self.storex.barcode_scanner = scanner + plate = make_plate() + self.rack.sites[0].assign_child_resource(plate) + + result = await self.storex.scan_barcode(self.rack.sites[0]) + + self.assertIs(result, barcode) + self.assertIs(plate.barcode, barcode) + calls = [call.args[0] for call in self.storex._send_command.await_args_list] + self.assertLess(calls.index("ST 1910"), calls.index("RS 1910")) + self.assertIn("ST 1903", calls) + + +class TestSensorsAndSwapStation(unittest.IsolatedAsyncioTestCase): + async def test_sensors(self) -> None: + storex = make_storex() + storex._send_command = AsyncMock(side_effect=["OK", "1", "0", "1"]) + self.assertTrue(await storex.request_shovel_sensor()) + self.assertFalse(await storex.request_transfer_sensor()) + self.assertTrue(await storex.request_second_transfer_sensor()) + + async def test_swap_station_moves_only_when_needed(self) -> None: + storex = make_storex() + storex._send_command = AsyncMock(return_value="0") + await storex.move_swap_station_home() + self.assertEqual(storex._send_command.await_count, 1) + + storex._send_command.reset_mock() + storex._send_command.return_value = "1" + await storex.move_swap_station_home() + self.assertEqual( + [call.args[0] for call in storex._send_command.await_args_list], + ["RD 1912", "RS 1912"], + ) + + async def test_swap_station_moves_to_swapped(self) -> None: + storex = make_storex() + storex._send_command = AsyncMock(return_value="0") + await storex.move_swap_station_swapped() + self.assertEqual( + [call.args[0] for call in storex._send_command.await_args_list], + ["RD 1912", "ST 1912"], + ) + + +class TestErrors(unittest.IsolatedAsyncioTestCase): + async def test_empty_response(self) -> None: + storex = make_storex() + storex.io = AsyncMock() + storex.io.port = "/dev/null" + storex.io.read = AsyncMock(return_value=b"") + with self.assertRaises(RuntimeError): + await storex._send_command("RD 1915") + + async def test_controller_error(self) -> None: + storex = make_storex() + storex.io = AsyncMock() + storex.io.port = "/dev/null" + storex.io.read = AsyncMock(return_value=b"E1") + with self.assertRaises(StoreXControllerCommandError): + await storex._send_command("ST 1801") + + async def test_unknown_error(self) -> None: + storex = make_storex() + storex.io = AsyncMock() + storex.io.port = "/dev/null" + storex.io.read = AsyncMock(return_value=b"E9") + with self.assertRaisesRegex(RuntimeError, "Unknown error"): + await storex._send_command("ST 1801") + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index 06d86e3aef2..fb24b95aea7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,17 +18,20 @@ ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.1"] -sila = ["zeroconf>=0.131.0", "grpcio"] +distributed = ["grpcio>=1.62.3", "protobuf>=4.25.1"] +sila = ["PyLabRobot[distributed]", "zeroconf>=0.131.0"] +liconic-server = ["PyLabRobot[serial,distributed]"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] xarm = ["xarm-python-sdk"] -all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila,pico,xarm]"] +all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,distributed,sila,liconic-server,pico,xarm]"] test = [ "pytest", "pytest-timeout", ] dev = [ "PyLabRobot[all,test]", + "grpcio-tools==1.62.3", # lint/type "mypy==1.18.2", @@ -54,7 +57,13 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt"] +pylabrobot = [ + "visualizer/*", + "visualizer/img/*", + "liconic/storex/experimental_server/*.md", + "liconic/storex/experimental_server/*.proto", + "version.txt", +] [tool.ruff] line-length = 100