diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb new file mode 100644 index 00000000000..cd560339aaf --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb @@ -0,0 +1,1216 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", + "metadata": {}, + "source": [ + "# Byonoy Absorbance 96 Automate\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "
\n", + "\n", + "
    \n", + "
  • OEM Link
  • \n", + "
  • Communication Protocol / Hardware: HID (USB-A/C)
  • \n", + "
  • Communication Level: Firmware
  • \n", + "
  • VID:PID 16d0:1199
  • \n", + "
  • Takes a single SLAS-format 96-wellplate on the base/detection unit, enables movement of cap/illumination unit over it, and reads all 96 wells simultaneously.
  • \n", + "
\n", + "\n", + "
\n", + "
\n", + " Figure: Byonoy Absorbance 96 Automate - Illumination unit being moved onto detection unit\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "840adda3-0ea1-4e7c-b0cb-34dd2244de69", + "metadata": {}, + "source": [ + "---\n", + "## Setup Instructions (Physical)\n", + "\n", + "The Byonoy Absorbance 96 Automate is a an absorbance plate reader consisting of...\n", + "1. a `base` containing the liqht source,\n", + "2. a `reader_cap` containing the light detectors, and\n", + "3. a `cap_adapter` representing a simple resource_holder for the `reader_cap`\n", + "\n", + "It requires only one cable connections to be operational:\n", + "1. USB cable (USB-C at `base` end; USB-A at control PC end)" + ] + }, + { + "cell_type": "markdown", + "id": "e4aa8066-9eb5-4f8a-8d69-372712bdb3b5", + "metadata": {}, + "source": [ + "---\n", + "## Setup Instructions (Programmatic)\n", + "\n", + "If used with a liquid handler, first setup the liquid handler:" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "08a4f706-8a33-40e0-a768-4786e11754bb", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "from pylabrobot.io import LOG_LEVEL_IO\n", + "from datetime import datetime\n", + "\n", + "current_date = datetime.today().strftime('%Y-%m-%d')\n", + "protocol_mode = \"execution\"\n", + "\n", + "# Create the shared file handler once\n", + "fh = logging.FileHandler(f\"{current_date}_testing_{protocol_mode}.log\", mode=\"a\")\n", + "fh.setLevel(LOG_LEVEL_IO)\n", + "formatter = logging.Formatter(\n", + " \"%(asctime)s [%(levelname)s] %(name)s - %(message)s\"\n", + ")\n", + "fh.setFormatter(formatter)\n", + "\n", + "# Configure the main pylabrobot logger\n", + "logger_plr = logging.getLogger(\"pylabrobot\")\n", + "logger_plr.setLevel(LOG_LEVEL_IO)\n", + "if not any(isinstance(h, logging.FileHandler) and h.baseFilename == fh.baseFilename\n", + " for h in logger_plr.handlers):\n", + " logger_plr.addHandler(fh)\n", + "\n", + "# Other loggers can reuse the same file handler\n", + "logger_manager = logging.getLogger(\"manager\")\n", + "logger_device = logging.getLogger(\"device\")\n", + "\n", + "for logger in [logger_manager, logger_device]:\n", + " logger.setLevel(logging.DEBUG) # or logging.INFO\n", + " if not any(isinstance(h, logging.FileHandler) and h.baseFilename == fh.baseFilename\n", + " for h in logger.handlers):\n", + " logger.addHandler(fh)\n", + "\n", + "# START LOGGING\n", + "logger_manager.info(\"START AUTOMATED PROTOCOL\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "1fd4d917", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerChatterboxBackend\n", + "from pylabrobot.resources import STARDeck\n", + "\n", + "lh = LiquidHandler(deck=STARDeck(), backend=LiquidHandlerChatterboxBackend())" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "abde0e65", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Setting up the liquid handler.\n" + ] + } + ], + "source": [ + "await lh.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "165bd434-5899-4623-ac67-91d2aad55e7c", + "metadata": {}, + "source": [ + "Then generate a plate definition for the plate you want to read:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5be9a197", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources.coordinate import Coordinate\n", + "from pylabrobot.resources.cellvis.plates import CellVis_96_wellplate_350uL_Fb\n", + "\n", + "\n", + "plate = CellVis_96_wellplate_350uL_Fb(name='plate')\n", + "lh.deck.assign_child_resource(plate, location=Coordinate(0, 0, 0))" + ] + }, + { + "cell_type": "markdown", + "id": "bee933e6-b6df-4de7-aad1-40a2f0ba6721", + "metadata": {}, + "source": [ + "Now instantiate the Byonoy absorbance plate reader:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6aa99372", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading.byonoy import (\n", + " byonoy_absorbance_adapter,\n", + " byonoy_absorbance96_base_and_reader\n", + ")\n", + "\n", + "cap_adapter = byonoy_absorbance_adapter(name='cap_adapter')\n", + "\n", + "base, reader_cap = byonoy_absorbance96_base_and_reader(name='base', assign=True)\n", + "\n", + "lh.deck.assign_child_resource(cap_adapter, location=Coordinate(400, 0, 0))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a10f9bb9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Connected to Bynoy Absorbance 96 Automate (via HID with VID=5840:PID=4505) on b'DevSrvsID:4308410804'\n", + "Identified available wavelengths: [420, 600] nm\n" + ] + }, + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await reader_cap.setup(verbose=True)\n", + "\n", + "reader_cap.setup_finished" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7089dbcd-4c88-434a-8e71-bdbe05130908", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'path': b'DevSrvsID:4308410804',\n", + " 'vendor_id': 5840,\n", + " 'product_id': 4505,\n", + " 'serial_number': 'BYOMAA00058',\n", + " 'release_number': 512,\n", + " 'manufacturer_string': 'Byonoy GmbH',\n", + " 'product_string': 'Absorbance 96 Automate',\n", + " 'usage_page': 65280,\n", + " 'usage': 1,\n", + " 'interface_number': 0,\n", + " 'bus_type': }" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reader_cap.backend.io.device_info" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "49ecf2d4-f8ec-4ed1-8ed0-a8eecc04e584", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[420, 600]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reader_cap.backend.available_wavelengths" + ] + }, + { + "cell_type": "markdown", + "id": "29947461-c095-4c5c-a98f-dd434eea7472", + "metadata": {}, + "source": [ + "## Test Movement for Plate Reading" + ] + }, + { + "cell_type": "raw", + "id": "32de1568-625b-4114-ae55-df1c03ea9230", + "metadata": {}, + "source": [ + "# move the reader off the base\n", + "await lh.move_resource(reader_cap, Coordinate(200, 0, 0))" + ] + }, + { + "cell_type": "raw", + "id": "4199936d-efd1-423c-9714-20b0ae581e10", + "metadata": { + "scrolled": true + }, + "source": [ + "await lh.move_resource(plate, base.plate_holder)" + ] + }, + { + "cell_type": "raw", + "id": "b11f154e-2025-4092-9a52-fb14af1a1520", + "metadata": {}, + "source": [ + "await lh.move_resource(reader_cap, base.reader_holder)" + ] + }, + { + "cell_type": "raw", + "id": "0b975857-6b26-49c9-947d-db25763e332d", + "metadata": {}, + "source": [ + "adapter.assign_child_resource(base)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b2e6e986", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(ResourceHolder(name='cap_adapter', location=Coordinate(400.000, 000.000, 000.000), size_x=127.76, size_y=85.59, size_z=14.07, category=resource_holder),\n", + " ByonoyBase(name='base_base', location=None, size_x=138, size_y=95.7, size_z=27.7, category=None),\n", + " PlateReader(name='base_reader', location=Coordinate(000.000, 000.000, 010.660), size_x=138, size_y=95.7, size_z=0, category=None))" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cap_adapter, base, reader_cap" + ] + }, + { + "cell_type": "markdown", + "id": "1ccafe3d-56c1-405f-b79e-6d4f8930e49d", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Usage / Machine Features" + ] + }, + { + "cell_type": "markdown", + "id": "30619f34-af58-4a74-b4fd-e2d53033c2de", + "metadata": {}, + "source": [ + "### Query Machine Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "2254228f-2864-4174-a615-9d1aed119ad5", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[420, 600]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await reader_cap.backend.get_available_absorbance_wavelengths()" + ] + }, + { + "cell_type": "markdown", + "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", + "metadata": {}, + "source": [ + "### Measure Absorbance" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
01234567891011
00.000002-0.0000020.0000830.0000380.0000482.975314e-050.000075NoneNoneNoneNoneNone
10.0000620.0000510.0000400.0000180.0000643.082320e-050.000044NoneNoneNoneNoneNone
20.0000880.0000550.0000690.0000090.0000797.937726e-050.000078NoneNoneNoneNoneNone
30.0000800.0000500.0000090.0000690.0000673.182423e-050.000070NoneNoneNoneNoneNone
40.0000420.0000030.0001100.000005-0.000005-1.815412e-050.000070NoneNoneNoneNoneNone
50.0000550.000054-0.0000230.0000410.0000369.664112e-070.000039NoneNoneNoneNoneNone
60.0000460.0000250.0000190.0000170.0000393.658781e-050.000066NoneNoneNoneNoneNone
70.0000380.0000180.0000550.0000410.000034-3.216584e-05NaNNoneNoneNoneNoneNone
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 5 6 \\\n", + "0 0.000002 -0.000002 0.000083 0.000038 0.000048 2.975314e-05 0.000075 \n", + "1 0.000062 0.000051 0.000040 0.000018 0.000064 3.082320e-05 0.000044 \n", + "2 0.000088 0.000055 0.000069 0.000009 0.000079 7.937726e-05 0.000078 \n", + "3 0.000080 0.000050 0.000009 0.000069 0.000067 3.182423e-05 0.000070 \n", + "4 0.000042 0.000003 0.000110 0.000005 -0.000005 -1.815412e-05 0.000070 \n", + "5 0.000055 0.000054 -0.000023 0.000041 0.000036 9.664112e-07 0.000039 \n", + "6 0.000046 0.000025 0.000019 0.000017 0.000039 3.658781e-05 0.000066 \n", + "7 0.000038 0.000018 0.000055 0.000041 0.000034 -3.216584e-05 NaN \n", + "\n", + " 7 8 9 10 11 \n", + "0 None None None None None \n", + "1 None None None None None \n", + "2 None None None None None \n", + "3 None None None None None \n", + "4 None None None None None \n", + "5 None None None None None \n", + "6 None None None None None \n", + "7 None None None None None " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "readings_420_nested_list = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:55],\n", + " wavelength = 420, # units: nm\n", + " output_nested_list=True\n", + ")\n", + "\n", + "import pandas as pd\n", + "\n", + "pd.DataFrame(readings_420_nested_list)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9fccbccb-d569-4883-be04-290c639b99f0", + "metadata": {}, + "outputs": [], + "source": [ + "import time" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "fbf13573-8754-4a8d-8d26-93dff422ab22", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
01234567891011
00.0000970.0000790.0000870.0000920.0000850.0000970.0000860.0000880.0000740.0001110.0000660.000076
10.0000500.0000740.0000630.0000540.0000730.0000660.0000500.0000610.0000820.0000950.0000510.000059
20.0000930.0000490.0000310.0000810.0000670.0000830.0000660.0001040.0000740.0000640.0000400.000069
30.0000960.0000740.0000230.0000750.0001000.0000530.0000640.0000870.0000700.0000730.0000500.000054
40.0000870.0000740.0001610.0000700.0000800.0000690.0001010.0001060.0001120.0001030.0000590.000062
50.0000580.0000670.0000230.0000680.0000360.0000530.0000350.0000440.0000450.0000970.0000390.000033
60.0000800.0000360.0000120.0000790.0000620.0000610.0000460.0000840.0000430.0000500.0000260.000064
70.0000870.0000530.0000720.0000600.0000760.0000310.0000340.0000840.0000860.0000540.0000320.000079
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 5 6 \\\n", + "0 0.000097 0.000079 0.000087 0.000092 0.000085 0.000097 0.000086 \n", + "1 0.000050 0.000074 0.000063 0.000054 0.000073 0.000066 0.000050 \n", + "2 0.000093 0.000049 0.000031 0.000081 0.000067 0.000083 0.000066 \n", + "3 0.000096 0.000074 0.000023 0.000075 0.000100 0.000053 0.000064 \n", + "4 0.000087 0.000074 0.000161 0.000070 0.000080 0.000069 0.000101 \n", + "5 0.000058 0.000067 0.000023 0.000068 0.000036 0.000053 0.000035 \n", + "6 0.000080 0.000036 0.000012 0.000079 0.000062 0.000061 0.000046 \n", + "7 0.000087 0.000053 0.000072 0.000060 0.000076 0.000031 0.000034 \n", + "\n", + " 7 8 9 10 11 \n", + "0 0.000088 0.000074 0.000111 0.000066 0.000076 \n", + "1 0.000061 0.000082 0.000095 0.000051 0.000059 \n", + "2 0.000104 0.000074 0.000064 0.000040 0.000069 \n", + "3 0.000087 0.000070 0.000073 0.000050 0.000054 \n", + "4 0.000106 0.000112 0.000103 0.000059 0.000062 \n", + "5 0.000044 0.000045 0.000097 0.000039 0.000033 \n", + "6 0.000084 0.000043 0.000050 0.000026 0.000064 \n", + "7 0.000084 0.000086 0.000054 0.000032 0.000079 " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1.5100939273834229" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "start_time = time.time()\n", + "\n", + "readings_600_nested_list = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:],\n", + " wavelength = 600, # units: nm\n", + " output_nested_list=True\n", + ")\n", + "display(pd.DataFrame(readings_600_nested_list))\n", + "\n", + "\n", + "time.time() - start_time" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "a6f77438-147e-4e3d-bcf8-dbfa0f443a46", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
01234567891011
00.0000780.0000650.0000780.0000990.0000810.0000800.0000790.0000880.0000550.0001010.0000700.000081
10.0000440.0000610.0000760.0000540.0000540.0000650.0000540.0000690.0000550.0000920.0000460.000070
20.0000720.0000520.0000300.0000730.0000660.0000800.0000550.0000950.0000520.0000560.0000500.000068
30.0000850.0000680.0000520.0000750.0000920.0000570.0000880.0000850.0000710.0000710.0000620.000059
40.0000940.0000620.0001620.0000790.0000800.0000510.0000860.0001060.0001030.0000800.0000600.000072
50.0000410.0000650.0000290.0000680.0000210.0000510.0000280.0000470.0000500.0000950.0000410.000039
60.0000690.0000480.0000200.0000820.0000580.0000570.0000440.0000780.0000500.0000520.0000370.000062
70.0000860.0000570.0000760.0000710.0000660.0000330.0000480.0000860.0000810.0000600.0000480.000079
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 5 6 \\\n", + "0 0.000078 0.000065 0.000078 0.000099 0.000081 0.000080 0.000079 \n", + "1 0.000044 0.000061 0.000076 0.000054 0.000054 0.000065 0.000054 \n", + "2 0.000072 0.000052 0.000030 0.000073 0.000066 0.000080 0.000055 \n", + "3 0.000085 0.000068 0.000052 0.000075 0.000092 0.000057 0.000088 \n", + "4 0.000094 0.000062 0.000162 0.000079 0.000080 0.000051 0.000086 \n", + "5 0.000041 0.000065 0.000029 0.000068 0.000021 0.000051 0.000028 \n", + "6 0.000069 0.000048 0.000020 0.000082 0.000058 0.000057 0.000044 \n", + "7 0.000086 0.000057 0.000076 0.000071 0.000066 0.000033 0.000048 \n", + "\n", + " 7 8 9 10 11 \n", + "0 0.000088 0.000055 0.000101 0.000070 0.000081 \n", + "1 0.000069 0.000055 0.000092 0.000046 0.000070 \n", + "2 0.000095 0.000052 0.000056 0.000050 0.000068 \n", + "3 0.000085 0.000071 0.000071 0.000062 0.000059 \n", + "4 0.000106 0.000103 0.000080 0.000060 0.000072 \n", + "5 0.000047 0.000050 0.000095 0.000041 0.000039 \n", + "6 0.000078 0.000050 0.000052 0.000037 0.000062 \n", + "7 0.000086 0.000081 0.000060 0.000048 0.000079 " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "5.985895156860352" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "start_time = time.time()\n", + "\n", + "readings_600_nested_list = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:],\n", + " wavelength = 600, # units: nm\n", + " output_nested_list=True,\n", + " num_measurement_replicates=5\n", + ")\n", + "display(pd.DataFrame(readings_600_nested_list))\n", + "\n", + "time.time() - start_time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "60719244-d75d-4e34-bb89-266608837ff0", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "1749dc00-c760-4993-b374-fb2ee09d2175", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
420nm600nm
A10.0000640.000100
B10.0000970.000033
C10.0001650.000086
D10.0001050.000082
E10.0001060.000132
.........
D80.0000730.000117
E80.0000850.000107
F80.0000570.000053
G80.0001240.000102
H80.0000790.000128
\n", + "

64 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " 420nm 600nm\n", + "A1 0.000064 0.000100\n", + "B1 0.000097 0.000033\n", + "C1 0.000165 0.000086\n", + "D1 0.000105 0.000082\n", + "E1 0.000106 0.000132\n", + ".. ... ...\n", + "D8 0.000073 0.000117\n", + "E8 0.000085 0.000107\n", + "F8 0.000057 0.000053\n", + "G8 0.000124 0.000102\n", + "H8 0.000079 0.000128\n", + "\n", + "[64 rows x 2 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "first_n_columns = 8\n", + "\n", + "readings_420 = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:8*first_n_columns],\n", + " wavelength = 420 # units: nm\n", + ")\n", + "readings_600 = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:8*first_n_columns],\n", + " wavelength = 600 # units: nm\n", + ")\n", + "\n", + "well_indexed_df = pd.DataFrame([readings_420, readings_600], index=[\"420nm\", \"600nm\"]).T\n", + "well_indexed_df" + ] + }, + { + "cell_type": "markdown", + "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", + "metadata": {}, + "source": [ + "## Disconnect from Reader" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "21a72488", + "metadata": {}, + "outputs": [], + "source": [ + "await reader_cap.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62b8732a-8bd7-427d-85c3-ab900f2a48b6", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/02_analytical/plate-reading/img/byonoy_absorbance_96_automate.png b/docs/user_guide/02_analytical/plate-reading/img/byonoy_absorbance_96_automate.png new file mode 100644 index 00000000000..dfe4151929b Binary files /dev/null and b/docs/user_guide/02_analytical/plate-reading/img/byonoy_absorbance_96_automate.png differ diff --git a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb index 3bcda681fc4..0d85d119edb 100644 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb @@ -14,6 +14,7 @@ "\n", "bmg-clariostar\n", "cytation5\n", + "byonoy\n", "```\n", "\n", "This example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." diff --git a/pylabrobot/io/hid.py b/pylabrobot/io/hid.py index 2105ee87d66..fb8b5c610b5 100644 --- a/pylabrobot/io/hid.py +++ b/pylabrobot/io/hid.py @@ -40,12 +40,64 @@ def __init__(self, vid=0x03EB, pid=0x2023, serial_number: Optional[str] = None): raise RuntimeError("Cannot create a new HID object while capture or validation is active") async def setup(self): + """ + Sets up the HID device by enumerating connected devices, matching the specified + VID, PID, and optional serial number, and opening a connection to the device. + """ if not USE_HID: raise RuntimeError( f"This backend requires the `hid` package to be installed. Import error: {_HID_IMPORT_ERROR}" ) - self.device = hid.Device(vid=self.vid, pid=self.pid, serial=self.serial_number) + + # --- 1. Enumerate all HID devices --- + all_devices = hid.enumerate() + matching = [ + d for d in all_devices if d.get("vendor_id") == self.vid and d.get("product_id") == self.pid + ] + + # --- 2. No devices found --- + if not matching: + raise RuntimeError(f"No HID devices found for VID=0x{self.vid:04X}, PID=0x{self.pid:04X}.") + + # --- 3. Serial number specified: must match exactly 1 --- + if self.serial_number is not None: + matching_sn = [d for d in matching if d.get("serial_number") == self.serial_number] + + if not matching_sn: + raise RuntimeError( + f"No HID devices found with VID=0x{self.vid:04X}, PID=0x{self.pid:04X}, " + f"serial={self.serial_number}." + ) + + if len(matching_sn) > 1: + # Extremely unlikely, but must follow serial semantics + raise RuntimeError( + f"Multiple HID devices found with identical serial number " + f"{self.serial_number} for VID/PID {self.vid}:{self.pid}. " + "Ambiguous; cannot continue." + ) + + chosen = matching_sn[0] + + # --- 4. Serial number not specified: require exactly one device --- + else: + if len(matching) > 1: + raise RuntimeError( + f"Multiple HID devices detected for VID=0x{self.vid:04X}, " + f"PID=0x{self.pid:04X}.\n" + f"Serial numbers: {[d.get('serial_number') for d in matching]}\n" + "Please specify `serial_number=` explicitly." + ) + chosen = matching[0] + + # --- 5. Open the device --- + self.device = hid.Device( + path=chosen["path"] # safer than vid/pid/serial triple + ) self._executor = ThreadPoolExecutor(max_workers=1) + + self.device_info = chosen + logger.log(LOG_LEVEL_IO, "Opened HID device %s", self._unique_id) capturer.record(HIDCommand(device_id=self._unique_id, action="open", data="")) @@ -107,8 +159,9 @@ def _read(): if self._executor is None: raise RuntimeError("Call setup() first.") r = await loop.run_in_executor(self._executor, _read) - logger.log(LOG_LEVEL_IO, "[%s] read %s", self._unique_id, r) - capturer.record(HIDCommand(device_id=self._unique_id, action="read", data=r.hex())) + if len(r.hex()) != 0: + logger.log(LOG_LEVEL_IO, "[%s] read %s", self._unique_id, r) + capturer.record(HIDCommand(device_id=self._unique_id, action="read", data=r.hex())) return cast(bytes, r) def serialize(self): diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 84c5fe53e2f..53b5a88e973 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -2109,6 +2109,9 @@ async def drop_resource( raise RuntimeError("No resource picked up") resource = self._resource_pickup.resource + if isinstance(destination, Resource): + destination.check_can_drop_resource_here(resource) + # compute rotation based on the pickup_direction and drop_direction if self._resource_pickup.direction == direction: rotation_applied_by_move = 0 @@ -2458,7 +2461,7 @@ async def move_plate( **backend_kwargs, ) - def serialize(self): + def serialize(self) -> dict: return { **Resource.serialize(self), **Machine.serialize(self), diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index a0018232632..0edb73460d6 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -1,4 +1,10 @@ from .biotek_backend import Cytation5Backend, Cytation5ImagingConfig +from .byonoy import ( + ByonoyAbsorbance96AutomateBackend, + ByonoyLuminescence96AutomateBackend, + byonoy_absorbance96_base_and_reader, + byonoy_absorbance_adapter, +) from .chatterbox import PlateReaderChatterboxBackend from .clario_star_backend import CLARIOstarBackend from .image_reader import ImageReader diff --git a/pylabrobot/plate_reading/byonoy/__init__.py b/pylabrobot/plate_reading/byonoy/__init__.py new file mode 100644 index 00000000000..a375c57873d --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/__init__.py @@ -0,0 +1,2 @@ +from .byonoy import byonoy_absorbance96_base_and_reader, byonoy_absorbance_adapter +from .byonoy_backend import ByonoyAbsorbance96AutomateBackend, ByonoyLuminescence96AutomateBackend diff --git a/pylabrobot/plate_reading/byonoy/byonoy.py b/pylabrobot/plate_reading/byonoy/byonoy.py new file mode 100644 index 00000000000..f15caa22285 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy.py @@ -0,0 +1,152 @@ +from typing import Optional, Tuple + +from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend +from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder + + +def byonoy_absorbance_adapter(name: str) -> ResourceHolder: + return ResourceHolder( + name=name, + size_x=127.76, # measured + size_y=85.59, # measured + size_z=14.07, # measured + child_location=Coordinate( + x=-(138 - 127.76) / 2, # measured + y=-(95.7 - 85.59) / 2, # measured + z=14.07 - 2.45, # measured + ), + ) + + +class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder): + """Custom plate holder that checks if the reader sits on the parent base. + This check is used to prevent crashes (moving plate onto holder while reader is on the base).""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + pedestal_size_z: float = None, # type: ignore + child_location=Coordinate.zero(), + category="plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + pedestal_size_z=pedestal_size_z, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional["ByonoyBase"] = None + + def check_can_drop_resource_here(self, resource: Resource) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "ByonoyBase not assigned its plate holder. " + "Please assign a ByonoyBase instance to the plate holder." + ) + + if self._byonoy_base.reader_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while reader is on the base. " + "Please remove the reader from the base before dropping a resource." + ) + + super().check_can_drop_resource_here(resource) + + +class ByonoyBase(Resource): + def __init__(self, name, rotation=None, category=None, model=None, barcode=None): + super().__init__( + name=name, + size_x=138, + size_y=95.7, + size_z=27.7, + ) + + self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder( + name=self.name + "_plate_holder", + size_x=127.76, + size_y=85.59, + size_z=0, + child_location=Coordinate(x=(138 - 127.76) / 2, y=(95.7 - 85.59) / 2, z=27.7), + pedestal_size_z=0, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.reader_holder = ResourceHolder( + name=self.name + "_reader_holder", + size_x=138, + size_y=95.7, + size_z=0, + child_location=Coordinate(x=0, y=0, z=10.66), + ) + self.assign_child_resource(self.reader_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign=True + ): + if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder): + if self.plate_holder._byonoy_base is not None: + raise ValueError("ByonoyBase can only have one plate holder assigned.") + self.plate_holder._byonoy_base = self + return super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource) -> None: + raise RuntimeError( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or reader_holder to assign plates and the reader, respectively." + ) + + +def byonoy_absorbance96_base_and_reader(name: str, assign=True) -> Tuple[ByonoyBase, PlateReader]: + """Creates a ByonoyBase and a PlateReader instance.""" + byonoy_base = ByonoyBase(name=name + "_base") + reader = PlateReader( + name=name + "_reader", + size_x=138, + size_y=95.7, + size_z=0, + backend=ByonoyAbsorbance96AutomateBackend(), + ) + if assign: + byonoy_base.reader_holder.assign_child_resource(reader) + return byonoy_base, reader + + +# === absorbance === + +# total + +# x: 138 +# y: 95.7 +# z: 53.35 + +# base +# z = 27.7 +# z without skirt 25.25 + +# top +# z = 41.62 + +# adapter +# z = 14.07 + +# location of top wrt base +# z = 10.66 + +# pickup distance from top +# z = 7.45 + +# === lum === + +# x: 155.5 +# y: 95.7 +# z: 56.9 diff --git a/pylabrobot/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/plate_reading/byonoy/byonoy_backend.py new file mode 100644 index 00000000000..cb09661f615 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_backend.py @@ -0,0 +1,447 @@ +import abc +import asyncio +import enum +import re +import struct +import threading +import time +from typing import Dict, List, Optional + +from pylabrobot.io.hid import HID +from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well +from pylabrobot.utils.list import reshape_2d + + +class _ByonoyDevice(enum.Enum): + ABSORBANCE_96 = enum.auto() + LUMINESCENCE_96 = enum.auto() + + +class _ByonoyBase(PlateReaderBackend, metaclass=abc.ABCMeta): + """Base backend for Byonoy plate readers using HID communication. + Provides common functionality for different Byonoy machine types. + """ + + def __init__(self, pid: int, device_type: _ByonoyDevice) -> None: + self.io = HID(vid=0x16D0, pid=pid) + self._background_thread: Optional[threading.Thread] = None + self._stop_background = threading.Event() + self._ping_interval = 1.0 # Send ping every second + self._sending_pings = False # Whether to actively send pings + self._device_type = device_type + + async def setup(self) -> None: + """Set up the plate reader. This should be called before any other methods.""" + + await self.io.setup() + + await self.initialize_measurements() + + # Start background keep alive messages + self._stop_background.clear() + self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) + self._background_thread.start() + + async def stop(self) -> None: + """Close all connections to the plate reader and make sure setup() can be called again.""" + + # Stop background keep alive messages + self._stop_background.set() + if self._background_thread and self._background_thread.is_alive(): + self._background_thread.join(timeout=2.0) + + await self.io.stop() + + def _assemble_command( + self, report_id: int, payload_fmt: str, payload: list, routing_info: bytes + ) -> bytes: + # based on `encode_hid_report` function + + # Encode the payload + binary_payload = struct.pack(payload_fmt, *payload) + + # Encode the full report (header + payload) + header_fmt = " Optional[bytes]: + command = self._assemble_command( + report_id, payload_fmt=payload_fmt, payload=payload, routing_info=routing_info + ) + + await self.io.write(command) + if not wait_for_response: + return None + + response = b"" + + t0 = time.time() + while True: + if time.time() - t0 > 120: # read for 2 minutes max. typical is 1m5s. + raise TimeoutError("Reading luminescence data timed out after 2 minutes.") + + response = await self.io.read(64, timeout=30) + if len(response) == 0: + continue + + # if the first 2 bytes do not match, we continue reading + response_report_id, *_ = struct.unpack(" None: + """Background worker that sends periodic ping commands.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_until_complete(self._ping_loop()) + finally: + loop.close() + + async def _ping_loop(self) -> None: + """Main ping loop that runs in the background thread.""" + while not self._stop_background.is_set(): + if self._sending_pings: + # don't read in background thread, data might get lost here + # not needed? + pass + + self._stop_background.wait(self._ping_interval) + + def _start_background_pings(self) -> None: + self._sending_pings = True + + def _stop_background_pings(self) -> None: + self._sending_pings = False + + async def open(self) -> None: + raise NotImplementedError( + "byonoy cannot open by itself. you need to move the top module using a robot arm." + ) + + async def close(self, plate: Optional[Plate]) -> None: + raise NotImplementedError( + "byonoy cannot close by itself. you need to move the top module using a robot arm." + ) + + +class ByonoyAbsorbance96AutomateBackend(_ByonoyBase): + def __init__(self) -> None: + super().__init__(pid=0x1199, device_type=_ByonoyDevice.ABSORBANCE_96) + + async def setup(self, verbose: bool = False, **backend_kwargs): + """Set up the plate reader. This should be called before any other methods.""" + + # Call the base setup (opens HID) + await super().setup(**backend_kwargs) + + # After device is online, run reference initialisation + await self.initialize_measurements() + + self.available_wavelengths = await self.get_available_absorbance_wavelengths() + + msg = ( + f"Connected to Bynoy {self.io.device_info['product_string']} (via HID with " + f"VID={self.io.device_info['vendor_id']}:PID={self.io.device_info['product_id']}) " + f"on {self.io.device_info['path']}\n" + f"Identified available wavelengths: {self.available_wavelengths} nm" + ) + if verbose: + print(msg) + + async def get_available_absorbance_wavelengths(self) -> List[float]: + available_wavelengths_r = await self.send_command( + report_id=0x0330, + payload_fmt="<30h", + payload=[0] * 30, + wait_for_response=True, + routing_info=b"\x80\x40", + ) + assert available_wavelengths_r is not None, "Failed to get available wavelengths." + # cut out the first 2 bytes, then read the next 2 bytes as an integer + # 64 - 4 = 60. 60/2 = 30 16 bit integers + available_wavelengths = list(struct.unpack("<30h", available_wavelengths_r[2:62])) + available_wavelengths = [w for w in available_wavelengths if w != 0] + return available_wavelengths + + async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_reference: bool): + """Perform an absorbance measurement or reference measurement. + This contains all shared logic between initialization and real measurements.""" + + # (1) SUPPORTED_REPORTS_IN (0x0010) + await self.send_command( + report_id=0x0010, + payload_fmt=" 120: + raise TimeoutError("Measurement timeout.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + report_id = int.from_bytes(chunk[:2], "little") + + # Only handle the measurement packets + if report_id == 0x0500: + ( + seq, + seq_len, + signal_wl_nm, + reference_wl_nm, + duration_ms, + *row, + flags, + progress, + ) = struct.unpack(" List[List[Optional[float]]]: + raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") + + async def read_fluorescence( + self, + plate: Plate, + wells, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[List[Optional[float]]]: + raise NotImplementedError("Absorbance plate reader does not support fluorescence reading.") + + +class ByonoyLuminescence96AutomateBackend(_ByonoyBase): + def __init__(self) -> None: + super().__init__(pid=0x119B, device_type=_ByonoyDevice.LUMINESCENCE_96) + + async def read_absorbance(self, plate, wells, wavelength): + raise NotImplementedError( + "Luminescence plate reader does not support absorbance reading. Use ByonoyAbsorbance96Automate instead." + ) + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 + ) -> List[List[Optional[float]]]: + """integration_time: in seconds, default 2 s""" + + await self.send_command( + report_id=0x0010, # SUPPORTED_REPORTS_IN + payload_fmt=" 120: # read for 2 minutes max. typical is 1m5s. + raise TimeoutError("Reading luminescence data timed out after 2 minutes.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + report_id, *_ = struct.unpack(" List[List[Optional[float]]]: + raise NotImplementedError("Fluorescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/plate_reading/byonoy/byonoy_tests.py b/pylabrobot/plate_reading/byonoy/byonoy_tests.py new file mode 100644 index 00000000000..01c125fd22c --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_tests.py @@ -0,0 +1,54 @@ +import unittest +import unittest.mock + +from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend +from pylabrobot.plate_reading.byonoy import ( + byonoy_absorbance96_base_and_reader, + byonoy_absorbance_adapter, +) +from pylabrobot.resources import PLT_CAR_L5_DWP, CellVis_96_wellplate_350uL_Fb, Coordinate, STARDeck + + +class ByonoyResourceTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.base, self.reader = byonoy_absorbance96_base_and_reader(name="byonoy_test", assign=True) + self.adapter = byonoy_absorbance_adapter(name="byonoy_test_adapter") + + self.deck = STARDeck() + self.lh = LiquidHandler(deck=self.deck, backend=unittest.mock.Mock(spec=LiquidHandlerBackend)) + self.plate_carrier = PLT_CAR_L5_DWP(name="plate_carrier") + self.plate_carrier[1] = self.adapter + self.deck.assign_child_resource(self.plate_carrier, rails=28) + self.adapter.assign_child_resource(self.base) + self.plate_carrier[2] = self.plate = CellVis_96_wellplate_350uL_Fb(name="plate") + + async def test_move_reader_to_base(self): + # move reader to deck + await self.lh.move_resource(self.reader, to=Coordinate(x=400, y=209.995, z=100)) + + # move reader to base + await self.lh.move_resource( + self.reader, + self.base.reader_holder, + pickup_distance_from_top=7.45, + ) + assert self.reader.get_absolute_location() == Coordinate(x=706.48, y=162.145, z=204.38) + + async def test_move_plate_to_base(self): + self.reader.unassign() + await self.lh.move_resource( + self.plate, + self.base.plate_holder, + ) + assert self.plate.get_absolute_location() == Coordinate( + x=711.6, + y=167.2, + z=221.42, + ) + + async def test_move_plate_to_base_when_reader_present(self): + with self.assertRaises(RuntimeError): + await self.lh.move_resource( + self.plate, + self.base.plate_holder, + ) diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index 21d7f35a096..94988983fed 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -34,6 +34,7 @@ def __init__( backend: PlateReaderBackend, category: Optional[str] = None, model: Optional[str] = None, + child_location: Coordinate = Coordinate.zero(), ) -> None: ResourceHolder.__init__( self, @@ -43,6 +44,7 @@ def __init__( size_z=size_z, category=category, model=model, + child_location=child_location, ) Machine.__init__(self, backend=backend) self.backend: PlateReaderBackend = backend # fix type @@ -136,3 +138,6 @@ async def read_fluorescence( focal_height=focal_height, **backend_kwargs, ) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Machine.serialize(self)} diff --git a/pylabrobot/resources/carrier.py b/pylabrobot/resources/carrier.py index 7ec9e4cb162..d9720260576 100644 --- a/pylabrobot/resources/carrier.py +++ b/pylabrobot/resources/carrier.py @@ -6,8 +6,7 @@ from pylabrobot.resources.resource_holder import ResourceHolder, get_child_location from .coordinate import Coordinate -from .plate import Lid, Plate -from .plate_adapter import PlateAdapter +from .plate import Plate from .resource import Resource from .resource_stack import ResourceStack @@ -190,11 +189,6 @@ def assign_child_resource( "If a ResourceStack is assigned to a PlateHolder, the items " + f"must be Plates, not {type(resource.children[-1])}" ) - elif not isinstance(resource, (Plate, PlateAdapter, Lid)): - raise TypeError( - "PlateHolder can only store Plate, PlateAdapter or ResourceStack " - + f"resources, not {type(resource)}" - ) if isinstance(resource, Plate) and resource.plate_type != "skirted": raise ValueError("PlateHolder can only store plates that are skirted") return super().assign_child_resource(resource, location, reassign) diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index 15535ae2eff..80f16147386 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -308,3 +308,7 @@ def get_quadrant( wells.sort(key=lambda well: (well.location.x, -well.location.y)) # type: ignore return wells + + def check_can_drop_resource_here(self, resource: Resource) -> None: + if not isinstance(resource, Lid): + raise RuntimeError(f"Can only drop Lid resources onto Plate '{self.name}'.") diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 36228c04feb..f2e23dae348 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -217,7 +217,7 @@ def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coo """ if self.location is None: - raise NoLocationError(f"Resource {self.name} has no location.") + raise NoLocationError(f"Resource '{self.name}' has no location.") rotated_anchor = Coordinate( *matrix_vector_multiply_3x3( @@ -828,3 +828,9 @@ def get_highest_known_point(self) -> float: for resource in self.children: highest_point = max(highest_point, resource.get_highest_known_point()) return highest_point + + def check_can_drop_resource_here(self, resource: Resource) -> None: + """Check if a resource can be dropped onto this resource. + Will raise an error if the resource is not compatible with this resource. + """ + raise RuntimeError(f"Resource {resource.name} cannot be dropped onto resource {self.name}.") diff --git a/pylabrobot/resources/resource_holder.py b/pylabrobot/resources/resource_holder.py index 315c001da73..45c609795d0 100644 --- a/pylabrobot/resources/resource_holder.py +++ b/pylabrobot/resources/resource_holder.py @@ -76,3 +76,10 @@ def resource(self, resource: Optional[Resource]): def serialize(self): return {**super().serialize(), "child_location": serialize(self.child_location)} + + def check_can_drop_resource_here(self, resource: Resource) -> None: + if self.resource is not None and resource is not self.resource: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto resource holder {self.name} while it already has a resource. " + "Please remove the resource before dropping a new one." + ) diff --git a/pylabrobot/resources/resource_stack.py b/pylabrobot/resources/resource_stack.py index 2218e14ac3a..5075513742a 100644 --- a/pylabrobot/resources/resource_stack.py +++ b/pylabrobot/resources/resource_stack.py @@ -145,3 +145,7 @@ def get_top_item(self) -> Resource: raise ValueError("Stack is empty") return self.children[-1] + + def check_can_drop_resource_here(self, resource: Resource) -> None: + # for now, any resource can be dropped onto a stack. + pass