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..bff66eaa069
--- /dev/null
+++ b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb
@@ -0,0 +1,1200 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4",
+ "metadata": {},
+ "source": [
+ "# Byonoy Absorbance 96 Automate\n",
+ "\n",
+ "| Summary | Image |\n",
+ "|------------|--------|\n",
+ "|
- OEM Link
- Communication Protocol / Hardware: HID / USB-A/C
- Communication Level: Firmware
- VID:PID
16d0:1199 - Takes a single SLAS-format 96-wellplate on the detection unit, enables movement of the cap/illumination unit over it, and reads all 96 wells simultaneously.
- Up to 6 configurable absorbance wavelengths (dependent on specifications during purchase).
| 
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 (A96A) is a an absorbance plate reader consisting of...\n",
+ "1. a `detection_unit` containing the liqht sensors,\n",
+ "2. a `illumination_unit` containing the light source,\n",
+ "3. a `parking_unit` representing a simple resource_holder for the `illumination_unit`, and\n",
+ "4. an `sbs_adapter` which is an optional holder for the `detection_unit` or `parking_unit`, enabling placement of this machine onto a standard SLAS/SBS-format plate holder.\n",
+ "\n",
+ "### Communication\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",
+ " 0 | \n",
+ " 1 | \n",
+ " 2 | \n",
+ " 3 | \n",
+ " 4 | \n",
+ " 5 | \n",
+ " 6 | \n",
+ " 7 | \n",
+ " 8 | \n",
+ " 9 | \n",
+ " 10 | \n",
+ " 11 | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 0.000002 | \n",
+ " -0.000002 | \n",
+ " 0.000083 | \n",
+ " 0.000038 | \n",
+ " 0.000048 | \n",
+ " 2.975314e-05 | \n",
+ " 0.000075 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 0.000062 | \n",
+ " 0.000051 | \n",
+ " 0.000040 | \n",
+ " 0.000018 | \n",
+ " 0.000064 | \n",
+ " 3.082320e-05 | \n",
+ " 0.000044 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 0.000088 | \n",
+ " 0.000055 | \n",
+ " 0.000069 | \n",
+ " 0.000009 | \n",
+ " 0.000079 | \n",
+ " 7.937726e-05 | \n",
+ " 0.000078 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 0.000080 | \n",
+ " 0.000050 | \n",
+ " 0.000009 | \n",
+ " 0.000069 | \n",
+ " 0.000067 | \n",
+ " 3.182423e-05 | \n",
+ " 0.000070 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 0.000042 | \n",
+ " 0.000003 | \n",
+ " 0.000110 | \n",
+ " 0.000005 | \n",
+ " -0.000005 | \n",
+ " -1.815412e-05 | \n",
+ " 0.000070 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " 0.000055 | \n",
+ " 0.000054 | \n",
+ " -0.000023 | \n",
+ " 0.000041 | \n",
+ " 0.000036 | \n",
+ " 9.664112e-07 | \n",
+ " 0.000039 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 6 | \n",
+ " 0.000046 | \n",
+ " 0.000025 | \n",
+ " 0.000019 | \n",
+ " 0.000017 | \n",
+ " 0.000039 | \n",
+ " 3.658781e-05 | \n",
+ " 0.000066 | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ " | 7 | \n",
+ " 0.000038 | \n",
+ " 0.000018 | \n",
+ " 0.000055 | \n",
+ " 0.000041 | \n",
+ " 0.000034 | \n",
+ " -3.216584e-05 | \n",
+ " NaN | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ " None | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " 0 | \n",
+ " 1 | \n",
+ " 2 | \n",
+ " 3 | \n",
+ " 4 | \n",
+ " 5 | \n",
+ " 6 | \n",
+ " 7 | \n",
+ " 8 | \n",
+ " 9 | \n",
+ " 10 | \n",
+ " 11 | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 0.000097 | \n",
+ " 0.000079 | \n",
+ " 0.000087 | \n",
+ " 0.000092 | \n",
+ " 0.000085 | \n",
+ " 0.000097 | \n",
+ " 0.000086 | \n",
+ " 0.000088 | \n",
+ " 0.000074 | \n",
+ " 0.000111 | \n",
+ " 0.000066 | \n",
+ " 0.000076 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 0.000050 | \n",
+ " 0.000074 | \n",
+ " 0.000063 | \n",
+ " 0.000054 | \n",
+ " 0.000073 | \n",
+ " 0.000066 | \n",
+ " 0.000050 | \n",
+ " 0.000061 | \n",
+ " 0.000082 | \n",
+ " 0.000095 | \n",
+ " 0.000051 | \n",
+ " 0.000059 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 0.000093 | \n",
+ " 0.000049 | \n",
+ " 0.000031 | \n",
+ " 0.000081 | \n",
+ " 0.000067 | \n",
+ " 0.000083 | \n",
+ " 0.000066 | \n",
+ " 0.000104 | \n",
+ " 0.000074 | \n",
+ " 0.000064 | \n",
+ " 0.000040 | \n",
+ " 0.000069 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 0.000096 | \n",
+ " 0.000074 | \n",
+ " 0.000023 | \n",
+ " 0.000075 | \n",
+ " 0.000100 | \n",
+ " 0.000053 | \n",
+ " 0.000064 | \n",
+ " 0.000087 | \n",
+ " 0.000070 | \n",
+ " 0.000073 | \n",
+ " 0.000050 | \n",
+ " 0.000054 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 0.000087 | \n",
+ " 0.000074 | \n",
+ " 0.000161 | \n",
+ " 0.000070 | \n",
+ " 0.000080 | \n",
+ " 0.000069 | \n",
+ " 0.000101 | \n",
+ " 0.000106 | \n",
+ " 0.000112 | \n",
+ " 0.000103 | \n",
+ " 0.000059 | \n",
+ " 0.000062 | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " 0.000058 | \n",
+ " 0.000067 | \n",
+ " 0.000023 | \n",
+ " 0.000068 | \n",
+ " 0.000036 | \n",
+ " 0.000053 | \n",
+ " 0.000035 | \n",
+ " 0.000044 | \n",
+ " 0.000045 | \n",
+ " 0.000097 | \n",
+ " 0.000039 | \n",
+ " 0.000033 | \n",
+ "
\n",
+ " \n",
+ " | 6 | \n",
+ " 0.000080 | \n",
+ " 0.000036 | \n",
+ " 0.000012 | \n",
+ " 0.000079 | \n",
+ " 0.000062 | \n",
+ " 0.000061 | \n",
+ " 0.000046 | \n",
+ " 0.000084 | \n",
+ " 0.000043 | \n",
+ " 0.000050 | \n",
+ " 0.000026 | \n",
+ " 0.000064 | \n",
+ "
\n",
+ " \n",
+ " | 7 | \n",
+ " 0.000087 | \n",
+ " 0.000053 | \n",
+ " 0.000072 | \n",
+ " 0.000060 | \n",
+ " 0.000076 | \n",
+ " 0.000031 | \n",
+ " 0.000034 | \n",
+ " 0.000084 | \n",
+ " 0.000086 | \n",
+ " 0.000054 | \n",
+ " 0.000032 | \n",
+ " 0.000079 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " 0 | \n",
+ " 1 | \n",
+ " 2 | \n",
+ " 3 | \n",
+ " 4 | \n",
+ " 5 | \n",
+ " 6 | \n",
+ " 7 | \n",
+ " 8 | \n",
+ " 9 | \n",
+ " 10 | \n",
+ " 11 | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " 0.000078 | \n",
+ " 0.000065 | \n",
+ " 0.000078 | \n",
+ " 0.000099 | \n",
+ " 0.000081 | \n",
+ " 0.000080 | \n",
+ " 0.000079 | \n",
+ " 0.000088 | \n",
+ " 0.000055 | \n",
+ " 0.000101 | \n",
+ " 0.000070 | \n",
+ " 0.000081 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " 0.000044 | \n",
+ " 0.000061 | \n",
+ " 0.000076 | \n",
+ " 0.000054 | \n",
+ " 0.000054 | \n",
+ " 0.000065 | \n",
+ " 0.000054 | \n",
+ " 0.000069 | \n",
+ " 0.000055 | \n",
+ " 0.000092 | \n",
+ " 0.000046 | \n",
+ " 0.000070 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " 0.000072 | \n",
+ " 0.000052 | \n",
+ " 0.000030 | \n",
+ " 0.000073 | \n",
+ " 0.000066 | \n",
+ " 0.000080 | \n",
+ " 0.000055 | \n",
+ " 0.000095 | \n",
+ " 0.000052 | \n",
+ " 0.000056 | \n",
+ " 0.000050 | \n",
+ " 0.000068 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " 0.000085 | \n",
+ " 0.000068 | \n",
+ " 0.000052 | \n",
+ " 0.000075 | \n",
+ " 0.000092 | \n",
+ " 0.000057 | \n",
+ " 0.000088 | \n",
+ " 0.000085 | \n",
+ " 0.000071 | \n",
+ " 0.000071 | \n",
+ " 0.000062 | \n",
+ " 0.000059 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " 0.000094 | \n",
+ " 0.000062 | \n",
+ " 0.000162 | \n",
+ " 0.000079 | \n",
+ " 0.000080 | \n",
+ " 0.000051 | \n",
+ " 0.000086 | \n",
+ " 0.000106 | \n",
+ " 0.000103 | \n",
+ " 0.000080 | \n",
+ " 0.000060 | \n",
+ " 0.000072 | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " 0.000041 | \n",
+ " 0.000065 | \n",
+ " 0.000029 | \n",
+ " 0.000068 | \n",
+ " 0.000021 | \n",
+ " 0.000051 | \n",
+ " 0.000028 | \n",
+ " 0.000047 | \n",
+ " 0.000050 | \n",
+ " 0.000095 | \n",
+ " 0.000041 | \n",
+ " 0.000039 | \n",
+ "
\n",
+ " \n",
+ " | 6 | \n",
+ " 0.000069 | \n",
+ " 0.000048 | \n",
+ " 0.000020 | \n",
+ " 0.000082 | \n",
+ " 0.000058 | \n",
+ " 0.000057 | \n",
+ " 0.000044 | \n",
+ " 0.000078 | \n",
+ " 0.000050 | \n",
+ " 0.000052 | \n",
+ " 0.000037 | \n",
+ " 0.000062 | \n",
+ "
\n",
+ " \n",
+ " | 7 | \n",
+ " 0.000086 | \n",
+ " 0.000057 | \n",
+ " 0.000076 | \n",
+ " 0.000071 | \n",
+ " 0.000066 | \n",
+ " 0.000033 | \n",
+ " 0.000048 | \n",
+ " 0.000086 | \n",
+ " 0.000081 | \n",
+ " 0.000060 | \n",
+ " 0.000048 | \n",
+ " 0.000079 | \n",
+ "
\n",
+ " \n",
+ "
\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",
+ " 420nm | \n",
+ " 600nm | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | A1 | \n",
+ " 0.000064 | \n",
+ " 0.000100 | \n",
+ "
\n",
+ " \n",
+ " | B1 | \n",
+ " 0.000097 | \n",
+ " 0.000033 | \n",
+ "
\n",
+ " \n",
+ " | C1 | \n",
+ " 0.000165 | \n",
+ " 0.000086 | \n",
+ "
\n",
+ " \n",
+ " | D1 | \n",
+ " 0.000105 | \n",
+ " 0.000082 | \n",
+ "
\n",
+ " \n",
+ " | E1 | \n",
+ " 0.000106 | \n",
+ " 0.000132 | \n",
+ "
\n",
+ " \n",
+ " | ... | \n",
+ " ... | \n",
+ " ... | \n",
+ "
\n",
+ " \n",
+ " | D8 | \n",
+ " 0.000073 | \n",
+ " 0.000117 | \n",
+ "
\n",
+ " \n",
+ " | E8 | \n",
+ " 0.000085 | \n",
+ " 0.000107 | \n",
+ "
\n",
+ " \n",
+ " | F8 | \n",
+ " 0.000057 | \n",
+ " 0.000053 | \n",
+ "
\n",
+ " \n",
+ " | G8 | \n",
+ " 0.000124 | \n",
+ " 0.000102 | \n",
+ "
\n",
+ " \n",
+ " | H8 | \n",
+ " 0.000079 | \n",
+ " 0.000128 | \n",
+ "
\n",
+ " \n",
+ "
\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..284994f27cf
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 d35458037bd..2b1bb7d33aa 100644
--- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb
+++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb
@@ -13,6 +13,8 @@
":maxdepth: 1\n",
"\n",
"bmg-clariostar\n",
+ "cytation5\n",
+ "byonoy\n",
"cytation\n",
"synergyh1\n",
"```\n",
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 dc313ff89d6..94087a6397f 100644
--- a/pylabrobot/liquid_handling/liquid_handler.py
+++ b/pylabrobot/liquid_handling/liquid_handler.py
@@ -2040,6 +2040,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
@@ -2389,7 +2392,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 81cd7d1f03b..0e923c6ea99 100644
--- a/pylabrobot/plate_reading/__init__.py
+++ b/pylabrobot/plate_reading/__init__.py
@@ -6,6 +6,12 @@
CytationImagingConfig,
)
from .agilent_biotek_synergyh1_backend import SynergyH1Backend
+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..e66bddef519
--- /dev/null
+++ b/pylabrobot/plate_reading/byonoy/byonoy.py
@@ -0,0 +1,321 @@
+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, Plate
+
+
+# NEW RESOURCE MODELLING SYSTEM FOR BYONOY A96A
+
+def byonoy_sbs_adapter(name: str) -> ResourceHolder:
+ """Create a Byonoy SBS adapter `ResourceHolder`.
+
+ This helper returns a `ResourceHolder` describing the physical footprint of the
+ Byonoy SBS adapter and the default coordinate transform from the adapter frame
+ to its child frame.
+
+ The adapter is modeled as a cuboid with fixed outer dimensions.
+ `child_location` encodes the child-frame origin offset assuming the SBS-adapter
+ is symmetrically centered ("cc") relative to the detection_unit "cc" alignment reference.
+ """
+ return ResourceHolder(
+ name=name,
+ size_x=127.76,
+ size_y=85.48,
+ size_z=17.0,
+ child_location=Coordinate(
+ x=-(155.26 - 127.76) / 2,
+ y=-(95.48 - 85.48) / 2,
+ z=17.0,
+ ),
+ )
+
+def byonoy_a96a_illumination_unit(
+ name: str,
+) -> Resource:
+ """
+ """
+ return Resource(
+ name=name,
+ size_x=155.26,
+ size_y=95.48,
+ size_z=42.898,
+ model="Byonoy A96A Illumination Unit",
+ )
+
+
+def byonoy_a96a_detection_unit(
+ name: str,
+ model: str = "Byonoy A96A Detection Unit",
+) -> ResourceHolder:
+ """Create a Byonoy A96A detection unit `ResourceHolder`.
+
+ The detection unit is modeled as a fixed-size rectangular prism. The
+ `child_location` specifies the default origin of the child frame within the
+ detection unit's frame (an internal alignment/reference point used for placing
+ child resources).
+
+ Args:
+ name: Resource name to assign to the returned `ResourceHolder`.
+ model: Model string stored on the `ResourceHolder`. Defaults to
+ "Byonoy A96A Detection Unit".
+
+ Returns:
+ A configured `ResourceHolder` instance representing the detection unit.
+ """
+
+
+
+
+ return ResourceHolder(
+ name=name,
+ size_x=155.26,
+ size_y=95.48,
+ size_z=18.5,
+ child_location=Coordinate(
+ x=22.5,
+ y=5.0,
+ z=16.0,
+ ),
+ model=model,
+ )
+
+
+def byonoy_a96a_parking_unit(name: str) -> ResourceHolder:
+ """Create a Byonoy A96A parking unit `ResourceHolder`.
+
+ This is equivalent to `byonoy_a96a_detection_unit(...)` but with the model
+ string set to "Byonoy A96A Parking Unit".
+
+ Args:
+ name: Resource name to assign to the returned `ResourceHolder`.
+
+ Returns:
+ A configured `ResourceHolder` instance representing the parking unit.
+ """
+ return byonoy_a96a_detection_unit(
+ name=name,
+ model="Byonoy A96A Parking Unit",
+ )
+
+
+class ByonoyA96ABaseUnit(ResourceHolder):
+ def __init__(self, name, rotation=None, category=None, model=None, barcode=None):
+ super().__init__(
+ name=name,
+ size_x=155.26,
+ size_y=95.48,
+ size_z=18.5,
+ child_location=Coordinate( # Dafault location for plate holder
+ x=22.5,
+ y=5.0,
+ z=16.0,
+ ),
+ )
+
+ child_location_map_per_model = { # Can be extended for future top units
+ "Byonoy A96A Illumination Unit": Coordinate(x=0.0, y=0.0, z=14.1),
+ }
+ self.assign_child_resource(self.plate_holder, location=Coordinate.zero())
+
+
+
+ def assign_child_resource(
+ self, resource: Resource, location: Optional[Coordinate] = None, reassign=True
+ ):
+
+ # Check there is no resource on the Byonoy base unit
+ if len(self.children) != 0:
+
+ # Check whether illumination_unit already on BaseUnit
+ if "Byonoy A96A Illumination Unit" in [
+ child.model for child in self.children
+ ]:
+ raise ValueError(
+ f"'{self.name}' already has an illumination unit assigned."
+ f"Cannot assign '{resource.name}' while an illumination unit"
+ " is already assigned."
+ )
+
+ # Check maximum number of child resources (plate holder + illumination unit)
+ if len(self.children) >= 2:
+ raise ValueError(
+ f"'{self.name}' already has maximum number of child resources assigned."
+ f"Cannot assign '{resource.name}'."
+ f" Current children: {[child.name for child in self.children]}."
+ )
+ # Assign child location based on model
+
+
+
+
+ if location is None:
+ location = self.child_location
+
+ # Check if the resource is a Byonoy A96A Illumination Unit
+ if resource.model == "Byonoy A96A Illumination Unit":
+
+ location = self.child_location_map_per_model[resource.model]
+
+ elif isinstance(resource, Plate):
+
+ location = self.child_location
+
+ 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."
+ )
+
+
+
+
+# OLD MODEL
+
+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..d095b3f1095
--- /dev/null
+++ b/pylabrobot/plate_reading/byonoy/byonoy_backend.py
@@ -0,0 +1,414 @@
+import abc
+import asyncio
+import enum
+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()
+
+ # 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[dict]:
+ """
+ Measure sample absorbance in each well at the specified wavelength.
+
+ Args:
+ wavelength: Signal wavelength in nanometers.
+ plate: The plate being read. Included for API uniformity.
+ wells, Subset of wells to return. If omitted, all 96 wells are returned.
+ num_measurement_replicates: Number of technical replicate reads to acquire. Replicates are taken sequentially and averaged per well. (Handled at the backend level for faster acquisition and a simpler interface.)
+ """
+
+ assert (
+ wavelength in self.available_wavelengths
+ ), f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}."
+
+ # 1. Collect technical replicates
+ technical_replicates = []
+ for _ in range(num_measurement_replicates):
+ rows = await self._run_abs_measurement(
+ signal_wl=wavelength,
+ reference_wl=0,
+ is_reference=False,
+ )
+ technical_replicates.append(rows)
+
+ # 2. Average the replicates (flat 96-element list)
+ if num_measurement_replicates == 1:
+ averaged_rows = technical_replicates[0]
+ else:
+ averaged_rows = [
+ sum(rep[i] for rep in technical_replicates) / num_measurement_replicates for i in range(96)
+ ]
+
+ # 3. Convert flat -> 8x12 matrix
+ matrix = reshape_2d(averaged_rows, (8, 12))
+
+ # dictionary output for filtered wells
+ return [
+ {
+ "wavelength": wavelength,
+ "time": time.time(),
+ "temperature": None,
+ "data": matrix,
+ }
+ ]
+
+ async def read_luminescence(
+ self, plate: Plate, wells: List[Well], focal_height: float
+ ) -> List[dict]:
+ 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[dict]:
+ 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) -> List[Dict]:
+ 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[Dict]:
+ """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[Dict]:
+ 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 fc78b40a83a..26036b46456 100644
--- a/pylabrobot/plate_reading/plate_reader.py
+++ b/pylabrobot/plate_reading/plate_reader.py
@@ -32,8 +32,9 @@ def __init__(
size_y: float,
size_z: float,
backend: PlateReaderBackend,
- category: Optional[str] = None,
+ category: Optional[str] = "plate_reader",
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
@@ -187,7 +189,6 @@ async def read_fluorescence(
focal_height=focal_height,
**backend_kwargs,
)
-
if not use_new_return_type:
logger.warning(
"The return type of read_fluorescence will change in a future version. Please set "
@@ -195,3 +196,6 @@ async def read_fluorescence(
)
return result[0]["data"] # type: ignore[no-any-return]
return result
+
+ 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 879187b8796..df8a9873cb2 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