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..620385c5720 --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb @@ -0,0 +1,370 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", + "metadata": {}, + "source": [ + "# Byonoy Absorbance 96 Automate\n", + "\n", + "| Summary | Photo |\n", + "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", + "| - [OEM Link](https://byonoy.com/absorbance-automate/)
- **Communication Protocol / Hardware**: Serial (HID)/ USB-A
- **Communication Level**: Firmware
- VID:PID 16d0:1199
- Serial: \"BYOMAA00058\"
- | ![quadrants](img/byonoy_absorbance_96_automate.png) |" + ] + }, + { + "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": "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": 2, + "id": "abde0e65", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Setting up the liquid handler.\n", + "Resource deck was assigned to the liquid handler.\n", + "Resource trash was assigned to the liquid handler.\n", + "Resource trash_core96 was assigned to the liquid handler.\n", + "Resource waste_block was assigned to 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": 3, + "id": "5be9a197", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Resource plate was assigned to the liquid handler.\n" + ] + } + ], + "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": 4, + "id": "6aa99372", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Resource cap_adapter was assigned to the liquid handler.\n" + ] + } + ], + "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": 5, + "id": "a10f9bb9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await reader_cap.setup()\n", + "\n", + "reader_cap.setup_finished" + ] + }, + { + "cell_type": "raw", + "id": "860c3830-e2a8-4ae3-a4bb-586ae993d849", + "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": 6, + "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": 6, + "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": 7, + "id": "2254228f-2864-4174-a615-9d1aed119ad5", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "ename": "CancelledError", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mCancelledError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m reader_cap.backend.get_available_absorbance_wavelengths()\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/plate_reading/byonoy/byonoy_backend.py:135\u001b[39m, in \u001b[36mByonoyAbsorbance96AutomateBackend.get_available_absorbance_wavelengths\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 134\u001b[39m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mget_available_absorbance_wavelengths\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> List[\u001b[38;5;28mfloat\u001b[39m]:\n\u001b[32m--> \u001b[39m\u001b[32m135\u001b[39m available_wavelengths_r = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.send_command(\n\u001b[32m 136\u001b[39m report_id=\u001b[32m0x0330\u001b[39m,\n\u001b[32m 137\u001b[39m payload_fmt=\u001b[33m\"\u001b[39m\u001b[33m<30h\u001b[39m\u001b[33m\"\u001b[39m,\n\u001b[32m 138\u001b[39m payload=[\u001b[32m0\u001b[39m] * \u001b[32m30\u001b[39m,\n\u001b[32m 139\u001b[39m wait_for_response=\u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[32m 140\u001b[39m )\n\u001b[32m 141\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m available_wavelengths_r \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"\u001b[39m\u001b[33mFailed to get available wavelengths.\u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 142\u001b[39m \u001b[38;5;66;03m# cut out the first 2 bytes, then read the next 2 bytes as an integer\u001b[39;00m\n\u001b[32m 143\u001b[39m \u001b[38;5;66;03m# 64 - 4 = 60. 60/2 = 30 16 bit integers\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/plate_reading/byonoy/byonoy_backend.py:80\u001b[39m, in \u001b[36m_ByonoyBase.send_command\u001b[39m\u001b[34m(self, report_id, payload_fmt, payload, wait_for_response)\u001b[39m\n\u001b[32m 77\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m time.time() - t0 > \u001b[32m120\u001b[39m: \u001b[38;5;66;03m# read for 2 minutes max. typical is 1m5s.\u001b[39;00m\n\u001b[32m 78\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTimeoutError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mReading luminescence data timed out after 2 minutes.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m---> \u001b[39m\u001b[32m80\u001b[39m response = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.io.read(\u001b[32m64\u001b[39m, timeout=\u001b[32m30\u001b[39m)\n\u001b[32m 81\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(response) == \u001b[32m0\u001b[39m:\n\u001b[32m 82\u001b[39m \u001b[38;5;28;01mcontinue\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/io/hid.py:109\u001b[39m, in \u001b[36mHID.read\u001b[39m\u001b[34m(self, size, timeout)\u001b[39m\n\u001b[32m 107\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m._executor \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 108\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mRuntimeError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mCall setup() first.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m--> \u001b[39m\u001b[32m109\u001b[39m r = \u001b[38;5;28;01mawait\u001b[39;00m loop.run_in_executor(\u001b[38;5;28mself\u001b[39m._executor, _read)\n\u001b[32m 110\u001b[39m logger.log(LOG_LEVEL_IO, \u001b[33m\"\u001b[39m\u001b[33m[\u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m] read \u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m._unique_id, r)\n\u001b[32m 111\u001b[39m capturer.record(HIDCommand(device_id=\u001b[38;5;28mself\u001b[39m._unique_id, action=\u001b[33m\"\u001b[39m\u001b[33mread\u001b[39m\u001b[33m\"\u001b[39m, data=r.hex()))\n", + "\u001b[31mCancelledError\u001b[39m: " + ] + } + ], + "source": [ + "await reader_cap.backend.get_available_absorbance_wavelengths()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb5404f7-56c1-4647-b6cb-32606ac470f3", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", + "metadata": {}, + "source": [ + "### Measure Absorbance" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "761a587a-7084-40f8-9820-ef934230fb2f", + "metadata": {}, + "outputs": [ + { + "ename": "TimeoutError", + "evalue": "Reading luminescence data timed out after 2 minutes.", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mTimeoutError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[9]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m reader_cap.backend.read_absorbance(\n\u001b[32m 2\u001b[39m plate = CellVis_96_wellplate_350uL_Fb,\n\u001b[32m 3\u001b[39m wavelength = \u001b[32m600\u001b[39m\n\u001b[32m 4\u001b[39m )\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/plate_reading/byonoy/byonoy_backend.py:152\u001b[39m, in \u001b[36mByonoyAbsorbance96AutomateBackend.read_absorbance\u001b[39m\u001b[34m(self, plate, wavelength)\u001b[39m\n\u001b[32m 148\u001b[39m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mread_absorbance\u001b[39m(\u001b[38;5;28mself\u001b[39m, plate: Plate, wavelength: \u001b[38;5;28mint\u001b[39m) -> List[List[\u001b[38;5;28mfloat\u001b[39m]]:\n\u001b[32m 149\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"Read the absorbance from the plate reader. This should return a list of lists, where the\u001b[39;00m\n\u001b[32m 150\u001b[39m \u001b[33;03m outer list is the columns of the plate and the inner list is the rows of the plate.\"\"\"\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m152\u001b[39m available_wavelengths = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.get_available_absorbance_wavelengths()\n\u001b[32m 153\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m wavelength \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;129;01min\u001b[39;00m available_wavelengths:\n\u001b[32m 154\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\n\u001b[32m 155\u001b[39m \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mWavelength \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mwavelength\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m nm is not supported by this plate reader. \u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 156\u001b[39m \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mAvailable wavelengths: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00mavailable_wavelengths\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 157\u001b[39m )\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/plate_reading/byonoy/byonoy_backend.py:135\u001b[39m, in \u001b[36mByonoyAbsorbance96AutomateBackend.get_available_absorbance_wavelengths\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 134\u001b[39m \u001b[38;5;28;01masync\u001b[39;00m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mget_available_absorbance_wavelengths\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> List[\u001b[38;5;28mfloat\u001b[39m]:\n\u001b[32m--> \u001b[39m\u001b[32m135\u001b[39m available_wavelengths_r = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.send_command(\n\u001b[32m 136\u001b[39m report_id=\u001b[32m0x0330\u001b[39m,\n\u001b[32m 137\u001b[39m payload_fmt=\u001b[33m\"\u001b[39m\u001b[33m<30h\u001b[39m\u001b[33m\"\u001b[39m,\n\u001b[32m 138\u001b[39m payload=[\u001b[32m0\u001b[39m] * \u001b[32m30\u001b[39m,\n\u001b[32m 139\u001b[39m wait_for_response=\u001b[38;5;28;01mTrue\u001b[39;00m,\n\u001b[32m 140\u001b[39m )\n\u001b[32m 141\u001b[39m \u001b[38;5;28;01massert\u001b[39;00m available_wavelengths_r \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m, \u001b[33m\"\u001b[39m\u001b[33mFailed to get available wavelengths.\u001b[39m\u001b[33m\"\u001b[39m\n\u001b[32m 142\u001b[39m \u001b[38;5;66;03m# cut out the first 2 bytes, then read the next 2 bytes as an integer\u001b[39;00m\n\u001b[32m 143\u001b[39m \u001b[38;5;66;03m# 64 - 4 = 60. 60/2 = 30 16 bit integers\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/Desktop/GitHub/pylabrobot/pylabrobot/plate_reading/byonoy/byonoy_backend.py:78\u001b[39m, in \u001b[36m_ByonoyBase.send_command\u001b[39m\u001b[34m(self, report_id, payload_fmt, payload, wait_for_response)\u001b[39m\n\u001b[32m 76\u001b[39m \u001b[38;5;28;01mwhile\u001b[39;00m \u001b[38;5;28;01mTrue\u001b[39;00m:\n\u001b[32m 77\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m time.time() - t0 > \u001b[32m120\u001b[39m: \u001b[38;5;66;03m# read for 2 minutes max. typical is 1m5s.\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m78\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTimeoutError\u001b[39;00m(\u001b[33m\"\u001b[39m\u001b[33mReading luminescence data timed out after 2 minutes.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m 80\u001b[39m response = \u001b[38;5;28;01mawait\u001b[39;00m \u001b[38;5;28mself\u001b[39m.io.read(\u001b[32m64\u001b[39m, timeout=\u001b[32m30\u001b[39m)\n\u001b[32m 81\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mlen\u001b[39m(response) == \u001b[32m0\u001b[39m:\n", + "\u001b[31mTimeoutError\u001b[39m: Reading luminescence data timed out after 2 minutes." + ] + } + ], + "source": [ + "await reader_cap.backend.read_absorbance(\n", + " plate = CellVis_96_wellplate_350uL_Fb,\n", + " wavelength = 600 # units: nm\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", + "metadata": {}, + "source": [ + "## Disconnect from Reader" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "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/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..f15d2d35c3e --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_backend.py @@ -0,0 +1,326 @@ +import abc +import asyncio +import enum +import struct +import threading +import time +from typing import 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): + 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() + + # 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 read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[List[Optional[float]]]: + raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") + + 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 read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[List[Optional[float]]]: + """Read the absorbance from the plate reader. This should return a list of lists, where the + outer list is the columns of the plate and the inner list is the rows of the plate.""" + + available_wavelengths = await self.get_available_absorbance_wavelengths() + if wavelength not in available_wavelengths: + raise ValueError( + f"Wavelength {wavelength} nm is not supported by this plate reader. " + f"Available wavelengths: {available_wavelengths}" + ) + + await self.send_command( + report_id=0x0010, # SUPPORTED_REPORTS_IN + payload_fmt=" 120: # read for 2 minutes max. typical is 1m5s. + raise TimeoutError("Reading absorbance 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("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