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\"
- |  |"
+ ]
+ },
+ {
+ "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