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..6d8348cd102 --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/byonoy.ipynb @@ -0,0 +1,1577 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", + "metadata": {}, + "source": "# Byonoy Absorbance 96 Automate\n\n- [OEM Link](https://byonoy.com/absorbance-automate/)\n- **Communication Protocol / Hardware:** HID / USB-A/C\n- **Communication Level:** Firmware\n- VID:PID `16d0:1199`\n- Takes a single SLAS-format 96-wellplate on the detection unit, enables movement of the cap/illumination unit over it, and reads all 96 wells simultaneously.\n- Up to 6 configurable absorbance wavelengths (dependent on specifications during purchase)." + }, + { + "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": "raw", + "id": "fe2cca99-8ff6-431f-97c4-ed32211578af", + "metadata": {}, + "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": null, + "id": "2477b684-443f-4a2f-b16c-99649f443de4", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "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": "7355585a-e751-46b6-897b-d09c8be38852", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import (\n", + " hamilton_mfx_carrier_L5_base, MFX_CAR_L4_SHAKER , # MFX CARRIERS\n", + " MFX_DWP_rackbased_module, hamilton_mfx_plateholder_DWP_metal_tapped,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "20dbb226-5cc3-49f6-ac62-0c4f4e11eff9", + "metadata": {}, + "outputs": [], + "source": [ + "# \n", + "\n", + "mfx_carrier_2_plateholders = hamilton_mfx_carrier_L5_base(\n", + " name=\"mfx_carrier_2_plateholders\",\n", + " modules={\n", + " 4: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_1\"),\n", + " 2: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_parking_unit\"),\n", + " 0: hamilton_mfx_plateholder_DWP_metal_tapped(name=f\"mfx_plateholder_detection_unit\")\n", + " }\n", + ")\n", + "\n", + "lh.deck.assign_child_resource(mfx_carrier_2_plateholders, rails=12)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "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": 5, + "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", + "demo_plate = CellVis_96_wellplate_350uL_Fb(name='demo_plate')\n", + "\n", + "mfx_carrier_2_plateholders[4] = demo_plate" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading.byonoy import (\n", + " byonoy_sbs_adapter,\n", + " byonoy_a96a_detection_unit, \n", + " byonoy_a96a_illumination_unit,\n", + " byonoy_a96a_parking_unit\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "beb292d0-cc18-439f-b8c4-ae3013aa35d9", + "metadata": {}, + "outputs": [], + "source": [ + "# Detection Unit\n", + "a96a_sbs_adapter_DU = byonoy_sbs_adapter(name=\"a96a_sbs_adapter_DU\")\n", + "a96a_detection_unit = byonoy_a96a_detection_unit(name=\"a96a_detection_unit\")\n", + "a96a_sbs_adapter_DU.assign_child_resource(a96a_detection_unit)\n", + "\n", + "mfx_carrier_2_plateholders[0] = a96a_sbs_adapter_DU\n", + "\n", + "# Parking Unit\n", + "a96a_sbs_adapter_PU = byonoy_sbs_adapter(name=\"a96a_sbs_adapter_PU\")\n", + "a96a_parking_unit = byonoy_a96a_parking_unit(name=\"a96a_parking_unit\")\n", + "a96a_sbs_adapter_PU.assign_child_resource(a96a_parking_unit)\n", + "\n", + "mfx_carrier_2_plateholders[2] = a96a_sbs_adapter_PU\n", + "\n", + "\n", + "a96a_illumination_unit = byonoy_a96a_illumination_unit(name=\"a96a_illumination_unit\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "642ba0d5-f66a-48a6-a0b5-3ae609c39a0f", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" + ] + } + ], + "source": [ + "a96a_detection_unit.assign_child_resource(a96a_illumination_unit)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "7ee80f6a-02dd-4f10-8708-121f1ac9423b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Coordinate(x=337.75, y=67.0, z=200.95)" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "a96a_detection_unit.get_location_wrt(lh.deck)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2a473088-4d8e-43fd-ad20-d90f31f77741", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rail Resource Type Coordinates (mm)\n", + "===========================================================================================\n", + "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", + " │\n", + "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", + " │ ├── demo_plate Plate (351.500, 456.000, 182.050)\n", + " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", + " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", + " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", + " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", + " │\n", + "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", + " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", + " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", + " │\n", + "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", + "\n" + ] + } + ], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "81c0ef1a-a2cc-4094-b194-ee1781f782b3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rail Resource Type Coordinates (mm)\n", + "===============================================================================================\n", + "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", + " │\n", + "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", + " │ ├── demo_plate Plate (351.500, 456.000, 182.050)\n", + " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", + " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", + " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", + " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", + " │ │ │ ├── a96a_illumination_unitResource (337.750, 067.000, 215.050)\n", + " │\n", + "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", + " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", + " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", + " │\n", + "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", + "\n" + ] + } + ], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "5da9d7e2-fada-41b9-ab68-aff1e995b7d2", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up resource: ResourcePickup(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", + "Dropping resource: ResourceDrop(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), destination=Coordinate(x=337.75, y=259.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" + ] + } + ], + "source": [ + "await lh.move_resource(a96a_illumination_unit, a96a_parking_unit, pickup_distance_from_top=13.5)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "0cf647e4-1190-458d-a377-21fc31076419", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rail Resource Type Coordinates (mm)\n", + "===============================================================================================\n", + "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", + " │\n", + "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", + " │ ├── \n", + " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", + " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", + " │ │ │ ├── a96a_illumination_unitResource (337.750, 259.000, 215.050)\n", + " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", + " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", + " │\n", + "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", + " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", + " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", + " │\n", + "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", + "\n" + ] + } + ], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e11fc4d6-39fa-47cb-bec7-862a1ac85c9c", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54f6e3d0-d477-4acf-b6f0-80378c937181", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "raw", + "id": "f72c8d26-136d-4b40-9c7e-2876ced1aa01", + "metadata": {}, + "source": [ + "a96a_detection_unit.assign_child_resource(demo_plate)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "3668cae5-23c0-4004-a601-cc83ab89615d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up resource: ResourcePickup(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=9.87, direction=)\n", + "Dropping resource: ResourceDrop(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=None), destination=Coordinate(x=337.75, y=67.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=9.87, pickup_direction=, direction=, rotation=0)\n" + ] + } + ], + "source": [ + "await lh.move_plate(demo_plate, a96a_detection_unit)" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "400d6f72-2c5f-4ec7-90fd-42aa7cd83a0f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rail Resource Type Coordinates (mm)\n", + "===============================================================================================\n", + "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", + " │\n", + "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", + " │ ├── \n", + " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", + " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", + " │ │ │ ├── a96a_illumination_unitResource (337.750, 259.000, 215.050)\n", + " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", + " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", + " │ │ │ ├── demo_plate Plate (360.250, 072.000, 216.950)\n", + " │\n", + "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", + " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", + " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", + " │\n", + "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", + "\n" + ] + } + ], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e503fceb-092f-4687-b07b-ef4cc4146944", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "f9e0ddc2-bd3f-4802-b2da-021b89eadaa6", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Resource 'a96a_illumination_unit' is very high on the deck: 257.948 mm. Be careful when traversing the deck.\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up resource: ResourcePickup(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", + "Dropping resource: ResourceDrop(resource=Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None), destination=Coordinate(x=337.75, y=67.0, z=200.95), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" + ] + } + ], + "source": [ + "await lh.move_resource(a96a_illumination_unit, a96a_detection_unit, pickup_distance_from_top=13.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55efae40-199e-4e3d-8070-d153fd1734c2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1aaf6867-9254-4f41-9e63-e45bcea26fb6", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "b176939a-8e24-4c21-bd2b-f79799473a83", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Rail Resource Type Coordinates (mm)\n", + "===============================================================================================\n", + "(-6) ├── trash_core96 Trash (-58.200, 106.000, 216.400)\n", + " │\n", + "(12) ├── mfx_carrier_2_plateholders MFXCarrier (347.500, 063.000, 100.000)\n", + " │ ├── \n", + " │ ├── a96a_sbs_adapter_PU ResourceHolder (351.500, 264.000, 183.950)\n", + " │ │ ├── a96a_parking_unit ByonoyBaseUnit (337.750, 259.000, 200.950)\n", + " │ ├── a96a_sbs_adapter_DU ResourceHolder (351.500, 072.000, 183.950)\n", + " │ │ ├── a96a_detection_unit ByonoyBaseUnit (337.750, 067.000, 200.950)\n", + " │ │ │ ├── demo_plate Plate (360.250, 072.000, 216.950)\n", + " │ │ │ ├── a96a_illumination_unitResource (337.750, 067.000, 215.050)\n", + " │\n", + "(55) ├── waste_block Resource (1315.000, 115.000, 100.000)\n", + " │ ├── teaching_tip_rack TipRack (1320.900, 461.100, 100.000)\n", + " │ ├── core_grippers HamiltonCoreGrippers (1337.500, 125.000, 205.000)\n", + " │\n", + "(56) ├── trash Trash (1340.000, 190.600, 137.100)\n", + "\n" + ] + } + ], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2dab4931-8203-4697-a56c-175b41ace6c2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "1ead86de-3288-4fe6-9e9d-7bcbcfb7ad8a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up resource: ResourcePickup(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000)), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, direction=)\n", + "Dropping resource: ResourceDrop(resource=Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000)), destination=Coordinate(x=351.5, y=456.0, z=182.05), destination_absolute_rotation=Rotation(x=0, y=0, z=0), offset=Coordinate(x=0, y=0, z=0), pickup_distance_from_top=13.5, pickup_direction=, direction=, rotation=0)\n" + ] + } + ], + "source": [ + "await lh.move_resource(demo_plate, mfx_carrier_2_plateholders[4], pickup_distance_from_top=13.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30d3b220-0ac3-4f5d-a414-a241c4cead5e", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "0837df63-1ec6-4c53-9eef-826a57fecf95", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Coordinate(x=360.25, y=72.0, z=216.95)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "demo_plate.get_location_wrt(lh.deck)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "4e8af209-b3b0-4969-b107-4bf3f8394985", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Coordinate(x=337.75, y=67.0, z=200.95)" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "a96a_detection_unit.get_location_wrt(lh.deck)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "c720d02c-0a7b-47be-a79c-dde06e6213ce", + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'ByonoyBaseUnit' object has no attribute 'child_location'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[16]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m a96a_detection_unit.get_location_wrt(lh.deck)+ \u001b[43ma96a_detection_unit\u001b[49m\u001b[43m.\u001b[49m\u001b[43mchild_location\u001b[49m\n", + "\u001b[31mAttributeError\u001b[39m: 'ByonoyBaseUnit' object has no attribute 'child_location'" + ] + } + ], + "source": [ + "a96a_detection_unit.get_location_wrt(lh.deck)+ a96a_detection_unit.child_location" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40458d1a-a723-4f2d-a15d-ad50b736d6e6", + "metadata": {}, + "outputs": [], + "source": [ + "a96a_detection_unit.assign_child_resource(a96a_illumination_unit)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ede3638-c73f-4a34-a33a-b44cafde96df", + "metadata": {}, + "outputs": [], + "source": [ + "lh.summary()" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "3308a6e2-f31b-4a27-94ba-bf8adc56953b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ByonoyA96ABaseUnit(name='a96a_detection_unit', location=Coordinate(-13.750, -05.000, 017.000), size_x=155.26, size_y=95.48, size_z=18.5, category=resource_holder)\n" + ] + }, + { + "data": { + "text/plain": [ + "[None]" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[print(x) for x in a96a_sbs_adapter.children]" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "09deefed-475e-4a1a-a897-94d90ab3a98b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate(name='demo_plate', size_x=127.6, size_y=85.75, size_z=13.83, location=Coordinate(022.500, 005.000, 016.000))\n", + "Resource(name='a96a_illumination_unit', location=Coordinate(000.000, 000.000, 014.100), size_x=155.26, size_y=95.48, size_z=42.898, category=None)\n" + ] + }, + { + "data": { + "text/plain": [ + "[None, None]" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "[print(x) for x in a96a_detection_unit.children]" + ] + }, + { + "cell_type": "markdown", + "id": "bee933e6-b6df-4de7-aad1-40a2f0ba6721", + "metadata": {}, + "source": [ + "Now instantiate the Byonoy absorbance plate reader:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "6aa99372", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading.byonoy import (\n", + " byonoy_absorbance_adapter,\n", + " byonoy_absorbance96_base_and_reader\n", + ")\n", + "\n", + "cap_adapter = byonoy_absorbance_adapter(name='cap_adapter')\n", + "\n", + "base, reader_cap = byonoy_absorbance96_base_and_reader(name='base', assign=True)\n", + "\n", + "lh.deck.assign_child_resource(cap_adapter, location=Coordinate(400, 0, 0))" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "a10f9bb9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Connected to Bynoy Absorbance 96 Automate (via HID with VID=5840:PID=4505) on b'DevSrvsID:4308410804'\n", + "Identified available wavelengths: [420, 600] nm\n" + ] + }, + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await reader_cap.setup(verbose=True)\n", + "\n", + "reader_cap.setup_finished" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "7089dbcd-4c88-434a-8e71-bdbe05130908", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'path': b'DevSrvsID:4308410804',\n", + " 'vendor_id': 5840,\n", + " 'product_id': 4505,\n", + " 'serial_number': 'BYOMAA00058',\n", + " 'release_number': 512,\n", + " 'manufacturer_string': 'Byonoy GmbH',\n", + " 'product_string': 'Absorbance 96 Automate',\n", + " 'usage_page': 65280,\n", + " 'usage': 1,\n", + " 'interface_number': 0,\n", + " 'bus_type': }" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reader_cap.backend.io.device_info" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "49ecf2d4-f8ec-4ed1-8ed0-a8eecc04e584", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[420, 600]" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "reader_cap.backend.available_wavelengths" + ] + }, + { + "cell_type": "markdown", + "id": "29947461-c095-4c5c-a98f-dd434eea7472", + "metadata": {}, + "source": [ + "## Test Movement for Plate Reading" + ] + }, + { + "cell_type": "raw", + "id": "32de1568-625b-4114-ae55-df1c03ea9230", + "metadata": {}, + "source": [ + "# move the reader off the base\n", + "await lh.move_resource(reader_cap, Coordinate(200, 0, 0))" + ] + }, + { + "cell_type": "raw", + "id": "4199936d-efd1-423c-9714-20b0ae581e10", + "metadata": { + "scrolled": true + }, + "source": [ + "await lh.move_resource(plate, base.plate_holder)" + ] + }, + { + "cell_type": "raw", + "id": "b11f154e-2025-4092-9a52-fb14af1a1520", + "metadata": {}, + "source": [ + "await lh.move_resource(reader_cap, base.reader_holder)" + ] + }, + { + "cell_type": "raw", + "id": "0b975857-6b26-49c9-947d-db25763e332d", + "metadata": {}, + "source": [ + "adapter.assign_child_resource(base)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b2e6e986", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(ResourceHolder(name='cap_adapter', location=Coordinate(400.000, 000.000, 000.000), size_x=127.76, size_y=85.59, size_z=14.07, category=resource_holder),\n", + " ByonoyBase(name='base_base', location=None, size_x=138, size_y=95.7, size_z=27.7, category=None),\n", + " PlateReader(name='base_reader', location=Coordinate(000.000, 000.000, 010.660), size_x=138, size_y=95.7, size_z=0, category=None))" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "cap_adapter, base, reader_cap" + ] + }, + { + "cell_type": "markdown", + "id": "1ccafe3d-56c1-405f-b79e-6d4f8930e49d", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Usage / Machine Features" + ] + }, + { + "cell_type": "markdown", + "id": "30619f34-af58-4a74-b4fd-e2d53033c2de", + "metadata": {}, + "source": [ + "### Query Machine Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "2254228f-2864-4174-a615-9d1aed119ad5", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "data": { + "text/plain": [ + "[420, 600]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await reader_cap.backend.get_available_absorbance_wavelengths()" + ] + }, + { + "cell_type": "markdown", + "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", + "metadata": {}, + "source": [ + "### Measure Absorbance" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
01234567891011
00.000002-0.0000020.0000830.0000380.0000482.975314e-050.000075NoneNoneNoneNoneNone
10.0000620.0000510.0000400.0000180.0000643.082320e-050.000044NoneNoneNoneNoneNone
20.0000880.0000550.0000690.0000090.0000797.937726e-050.000078NoneNoneNoneNoneNone
30.0000800.0000500.0000090.0000690.0000673.182423e-050.000070NoneNoneNoneNoneNone
40.0000420.0000030.0001100.000005-0.000005-1.815412e-050.000070NoneNoneNoneNoneNone
50.0000550.000054-0.0000230.0000410.0000369.664112e-070.000039NoneNoneNoneNoneNone
60.0000460.0000250.0000190.0000170.0000393.658781e-050.000066NoneNoneNoneNoneNone
70.0000380.0000180.0000550.0000410.000034-3.216584e-05NaNNoneNoneNoneNoneNone
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 5 6 \\\n", + "0 0.000002 -0.000002 0.000083 0.000038 0.000048 2.975314e-05 0.000075 \n", + "1 0.000062 0.000051 0.000040 0.000018 0.000064 3.082320e-05 0.000044 \n", + "2 0.000088 0.000055 0.000069 0.000009 0.000079 7.937726e-05 0.000078 \n", + "3 0.000080 0.000050 0.000009 0.000069 0.000067 3.182423e-05 0.000070 \n", + "4 0.000042 0.000003 0.000110 0.000005 -0.000005 -1.815412e-05 0.000070 \n", + "5 0.000055 0.000054 -0.000023 0.000041 0.000036 9.664112e-07 0.000039 \n", + "6 0.000046 0.000025 0.000019 0.000017 0.000039 3.658781e-05 0.000066 \n", + "7 0.000038 0.000018 0.000055 0.000041 0.000034 -3.216584e-05 NaN \n", + "\n", + " 7 8 9 10 11 \n", + "0 None None None None None \n", + "1 None None None None None \n", + "2 None None None None None \n", + "3 None None None None None \n", + "4 None None None None None \n", + "5 None None None None None \n", + "6 None None None None None \n", + "7 None None None None None " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "readings_420_nested_list = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:55],\n", + " wavelength = 420, # units: nm\n", + " output_nested_list=True\n", + ")\n", + "\n", + "import pandas as pd\n", + "\n", + "pd.DataFrame(readings_420_nested_list)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9fccbccb-d569-4883-be04-290c639b99f0", + "metadata": {}, + "outputs": [], + "source": [ + "import time" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "fbf13573-8754-4a8d-8d26-93dff422ab22", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
01234567891011
00.0000970.0000790.0000870.0000920.0000850.0000970.0000860.0000880.0000740.0001110.0000660.000076
10.0000500.0000740.0000630.0000540.0000730.0000660.0000500.0000610.0000820.0000950.0000510.000059
20.0000930.0000490.0000310.0000810.0000670.0000830.0000660.0001040.0000740.0000640.0000400.000069
30.0000960.0000740.0000230.0000750.0001000.0000530.0000640.0000870.0000700.0000730.0000500.000054
40.0000870.0000740.0001610.0000700.0000800.0000690.0001010.0001060.0001120.0001030.0000590.000062
50.0000580.0000670.0000230.0000680.0000360.0000530.0000350.0000440.0000450.0000970.0000390.000033
60.0000800.0000360.0000120.0000790.0000620.0000610.0000460.0000840.0000430.0000500.0000260.000064
70.0000870.0000530.0000720.0000600.0000760.0000310.0000340.0000840.0000860.0000540.0000320.000079
\n", + "
" + ], + "text/plain": [ + " 0 1 2 3 4 5 6 \\\n", + "0 0.000097 0.000079 0.000087 0.000092 0.000085 0.000097 0.000086 \n", + "1 0.000050 0.000074 0.000063 0.000054 0.000073 0.000066 0.000050 \n", + "2 0.000093 0.000049 0.000031 0.000081 0.000067 0.000083 0.000066 \n", + "3 0.000096 0.000074 0.000023 0.000075 0.000100 0.000053 0.000064 \n", + "4 0.000087 0.000074 0.000161 0.000070 0.000080 0.000069 0.000101 \n", + "5 0.000058 0.000067 0.000023 0.000068 0.000036 0.000053 0.000035 \n", + "6 0.000080 0.000036 0.000012 0.000079 0.000062 0.000061 0.000046 \n", + "7 0.000087 0.000053 0.000072 0.000060 0.000076 0.000031 0.000034 \n", + "\n", + " 7 8 9 10 11 \n", + "0 0.000088 0.000074 0.000111 0.000066 0.000076 \n", + "1 0.000061 0.000082 0.000095 0.000051 0.000059 \n", + "2 0.000104 0.000074 0.000064 0.000040 0.000069 \n", + "3 0.000087 0.000070 0.000073 0.000050 0.000054 \n", + "4 0.000106 0.000112 0.000103 0.000059 0.000062 \n", + "5 0.000044 0.000045 0.000097 0.000039 0.000033 \n", + "6 0.000084 0.000043 0.000050 0.000026 0.000064 \n", + "7 0.000084 0.000086 0.000054 0.000032 0.000079 " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1.5100939273834229" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "start_time = time.time()\n", + "\n", + "readings_600_nested_list = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:],\n", + " wavelength = 600, # units: nm\n", + " output_nested_list=True\n", + ")\n", + "display(pd.DataFrame(readings_600_nested_list))\n", + "\n", + "\n", + "time.time() - start_time" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a6f77438-147e-4e3d-bcf8-dbfa0f443a46", + "metadata": {}, + "outputs": [], + "source": "start_time = time.time()\n\nreadings_600_nested_list = await reader_cap.backend.read_absorbance(\n wells=plate.children[:],\n wavelength = 600, # units: nm\n output_nested_list=True\n)\ndisplay(pd.DataFrame(readings_600_nested_list))\n\ntime.time() - start_time" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "60719244-d75d-4e34-bb89-266608837ff0", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "1749dc00-c760-4993-b374-fb2ee09d2175", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
420nm600nm
A10.0000640.000100
B10.0000970.000033
C10.0001650.000086
D10.0001050.000082
E10.0001060.000132
.........
D80.0000730.000117
E80.0000850.000107
F80.0000570.000053
G80.0001240.000102
H80.0000790.000128
\n", + "

64 rows × 2 columns

\n", + "
" + ], + "text/plain": [ + " 420nm 600nm\n", + "A1 0.000064 0.000100\n", + "B1 0.000097 0.000033\n", + "C1 0.000165 0.000086\n", + "D1 0.000105 0.000082\n", + "E1 0.000106 0.000132\n", + ".. ... ...\n", + "D8 0.000073 0.000117\n", + "E8 0.000085 0.000107\n", + "F8 0.000057 0.000053\n", + "G8 0.000124 0.000102\n", + "H8 0.000079 0.000128\n", + "\n", + "[64 rows x 2 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "first_n_columns = 8\n", + "\n", + "readings_420 = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:8*first_n_columns],\n", + " wavelength = 420 # units: nm\n", + ")\n", + "readings_600 = await reader_cap.backend.read_absorbance(\n", + " wells=plate.children[:8*first_n_columns],\n", + " wavelength = 600 # units: nm\n", + ")\n", + "\n", + "well_indexed_df = pd.DataFrame([readings_420, readings_600], index=[\"420nm\", \"600nm\"]).T\n", + "well_indexed_df" + ] + }, + { + "cell_type": "markdown", + "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", + "metadata": {}, + "source": [ + "## Disconnect from Reader" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "21a72488", + "metadata": {}, + "outputs": [], + "source": [ + "await reader_cap.stop()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62b8732a-8bd7-427d-85c3-ab900f2a48b6", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb new file mode 100644 index 00000000000..769788b5b78 --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/byonoy/absorbance.ipynb @@ -0,0 +1,231 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", + "metadata": {}, + "source": [ + "# Byonoy Absorbance 96 Automate\n", + "\n", + "- [OEM Link](https://byonoy.com/absorbance-automate/)\n", + "- **Communication Protocol / Hardware:** HID / USB-A/C\n", + "- **Communication Level:** Firmware\n", + "- VID:PID `16d0:1199`\n", + "- Takes a single SLAS-format 96-wellplate on the detection unit, enables movement of the cap/illumination unit over it, and reads all 96 wells simultaneously.\n", + "- Up to 6 configurable absorbance wavelengths (dependent on specifications during purchase)." + ] + }, + { + "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 light sensors,\n", + "2. a `illumination_unit` containing the light source,\n", + "3. a `parking_unit` representing a simple resource_holder for the `illumination_unit` that is equivalent to the detection unit in terms of shape, 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 connection 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)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4907224e", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading.byonoy import byonoy_a96a" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad91bfca", + "metadata": {}, + "outputs": [], + "source": [ + "reader, illumination_unit = byonoy_a96a(\"Absorbance96 Automate\")\n", + "reader" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f86d997", + "metadata": {}, + "outputs": [], + "source": [ + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "30619f34-af58-4a74-b4fd-e2d53033c2de", + "metadata": {}, + "source": [ + "### Query Machine Configuration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2254228f-2864-4174-a615-9d1aed119ad5", + "metadata": {}, + "outputs": [], + "source": [ + "wavelengths = await reader.backend.get_available_absorbance_wavelengths()\n", + "wavelengths" + ] + }, + { + "cell_type": "markdown", + "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", + "metadata": {}, + "source": [ + "### Measure Absorbance" + ] + }, + { + "cell_type": "markdown", + "id": "9c1d898e", + "metadata": {}, + "source": [ + "Before you can do a plate reading measurement in PLR, you need to assign a plate to the reader." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34f54968", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "demo_plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f6a50bd", + "metadata": {}, + "outputs": [], + "source": [ + "reader.assign_child_resource(demo_plate)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", + "metadata": {}, + "outputs": [], + "source": [ + "wavelength = wavelengths[0] # Choose the first available wavelength\n", + "\n", + "data = await reader.read_absorbance(\n", + " wavelength=wavelength,\n", + " use_new_return_type=True,\n", + ")\n", + "\n", + "print(f\"Wavelength: {data[0]['wavelength']} nm\")\n", + "print(f\"Time: {data[0]['time']}\")\n", + "print(f\"Temperature: {data[0]['temperature']}\")\n", + "print(\"Data\")\n", + "print(data[0]['data'])" + ] + }, + { + "cell_type": "markdown", + "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", + "metadata": {}, + "source": [ + "## Disconnect from Reader" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21a72488", + "metadata": {}, + "outputs": [], + "source": [ + "await reader.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "ad4bdba5", + "metadata": {}, + "source": [ + "## Resource model" + ] + }, + { + "cell_type": "markdown", + "id": "a2f610f3", + "metadata": {}, + "source": [ + "In the example above, we instantiated the Byonoy Absorbance 96 Automate reader using the `byonoy_a96a` function, which automatically creates the necessary resources for the reader." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e58d4ef", + "metadata": {}, + "outputs": [], + "source": [ + "reader.illumination_unit_holder.resource is illumination_unit" + ] + } + ], + "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.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb b/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb new file mode 100644 index 00000000000..7659ae4c54c --- /dev/null +++ b/docs/user_guide/02_analytical/plate-reading/byonoy/luminescence.ipynb @@ -0,0 +1,239 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63d8a7a6-1107-4334-8b73-598aa1ca97c4", + "metadata": {}, + "source": [ + "# Byonoy Luminescence 96 Automate\n", + "\n", + "- [OEM Link](https://byonoy.com/luminescence-automate/)\n", + "- **Communication Protocol / Hardware:** HID / USB-A/C\n", + "- **Communication Level:** Firmware\n", + "- VID:PID `16d0:119b`\n", + "- Takes a single SLAS-format 96-wellplate on the base unit, enables movement of the reader unit over it, and reads all 96 wells simultaneously." + ] + }, + { + "cell_type": "markdown", + "id": "840adda3-0ea1-4e7c-b0cb-34dd2244de69", + "metadata": {}, + "source": [ + "---\n", + "## Setup Instructions (Physical)\n", + "\n", + "The Byonoy Luminescence 96 Automate (L96A) is a luminescence plate reader consisting of...\n", + "1. a `base_unit` which holds the plate,\n", + "2. a `reader_unit` containing the light sensors that sits on top of the base unit.\n", + "\n", + "### Communication\n", + "It requires only one cable connection to be operational:\n", + "1. USB cable (USB-C at `reader_unit` end; USB-A at control PC end)" + ] + }, + { + "cell_type": "markdown", + "id": "e4aa8066-9eb5-4f8a-8d69-372712bdb3b5", + "metadata": {}, + "source": [ + "---\n", + "## Setup Instructions (Programmatic)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4907224e", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "38a9cf2d-840c-49ce-a886-da29c67fb124", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.plate_reading.byonoy import byonoy_l96a" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "ad91bfca", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "ByonoyLuminescence96Automate(name='Luminescence96 Automate_reader', location=Coordinate(000.000, 000.000, 006.300), size_x=138, size_y=97.5, size_z=41.7, category=plate_reader)" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "base_unit, reader_unit = byonoy_l96a(\"Luminescence96 Automate\")\n", + "reader_unit" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0f86d997", + "metadata": {}, + "outputs": [], + "source": [ + "await reader_unit.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "fc15c1b4-be77-4180-a5ce-d8a31480d0d4", + "metadata": {}, + "source": [ + "### Measure Luminescence" + ] + }, + { + "cell_type": "markdown", + "id": "9c1d898e", + "metadata": {}, + "source": [ + "Before you can do a plate reading measurement in PLR, you need to assign a plate to the reader." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "34f54968", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "demo_plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "8f6a50bd", + "metadata": {}, + "outputs": [], + "source": [ + "reader_unit.assign_child_resource(demo_plate)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e5a1d2e2-7b2c-4077-bde6-338f257b1993", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Time: 1770236622.314785\n", + "Temperature: None\n", + "Data\n", + "[[200552272.0, 201907664.0, 218108208.0, 234161952.0, 251950976.0, 8104904.0, 1781678.0, 1690456.0, 2708988.0, 2377269.0, 5855379.0, 289570432.0], [183959648.0, 183223568.0, 186845840.0, 187736528.0, 3184948.0, 1934996.0, 1431882.0, 1225921.0, 1186311.0, 1238371.0, 1392693.0, 3190326.0], [209724544.0, 200640128.0, 4695649.0, 2133116.0, 2067423.0, 1974764.0, 1769515.0, 1576723.0, 1396270.0, 1392762.0, 1163494.0, 1340111.0], [8631671.0, 2948252.0, 1878185.0, 1944314.0, 1928056.0, 1818789.0, 1538142.0, 1391728.0, 1203908.0, 1180876.0, 1094184.0, 1385255.0], [2561222.0, 2007909.0, 2202352.0, 2187162.0, 2016595.0, 1628429.0, 1532333.0, 1342909.0, 1310737.0, 1187646.0, 1123093.0, 1284595.0], [2612154.0, 2109698.0, 2374385.0, 2376566.0, 2073923.0, 1862185.0, 1761917.0, 1637089.0, 1429714.0, 1279758.0, 937221.0, 1471533.0], [3401529.0, 2545716.0, 2582033.0, 2696296.0, 2458846.0, 2448346.0, 2106862.0, 1956427.0, 1705456.0, 1578239.0, 1126263.0, 1378166.0], [6511548.0, 5467399.0, 6249315.0, 6408278.0, 5996644.0, 5163665.0, 5879837.0, 5222682.0, 4798715.0, 4238464.0, 3052718.0, 1889772.0]]\n" + ] + } + ], + "source": [ + "data = await reader_unit.read_luminescence(\n", + " focal_height=1,\n", + " use_new_return_type=True,\n", + ")\n", + "\n", + "print(f\"Time: {data[0]['time']}\")\n", + "print(f\"Temperature: {data[0]['temperature']}\")\n", + "print(\"Data\")\n", + "print(data[0]['data'])" + ] + }, + { + "cell_type": "markdown", + "id": "1a33230d-8243-4d21-88e1-4a4eb6cba7c8", + "metadata": {}, + "source": [ + "## Disconnect from Reader" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "21a72488", + "metadata": {}, + "outputs": [], + "source": [ + "await reader_unit.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "ad4bdba5", + "metadata": {}, + "source": [ + "## Resource model" + ] + }, + { + "cell_type": "markdown", + "id": "a2f610f3", + "metadata": {}, + "source": [ + "In the example above, we instantiated the Byonoy Luminescence 96 Automate reader using the `byonoy_l96a` function, which automatically creates the necessary resources for the reader." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "5e58d4ef", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "base_unit.reader_unit_holder.resource is reader_unit" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "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.9.25" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} 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..8220b490dbc 100644 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb @@ -4,21 +4,7 @@ "cell_type": "markdown", "id": "39d0c1a5", "metadata": {}, - "source": [ - "# Plate reading\n", - "\n", - "PyLabRobot supports the following plate readers:\n", - "\n", - "```{toctree}\n", - ":maxdepth: 1\n", - "\n", - "bmg-clariostar\n", - "cytation\n", - "synergyh1\n", - "```\n", - "\n", - "This example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." - ] + "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbmg-clariostar\nbyonoy/absorbance\nbyonoy/luminescence\nbyonoy\ncytation\nsynergyh1\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." }, { "cell_type": "code", @@ -432,4 +418,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/docs/user_guide/machines.md b/docs/user_guide/machines.md index cc6eee2a739..16b397e51d7 100644 --- a/docs/user_guide/machines.md +++ b/docs/user_guide/machines.md @@ -155,8 +155,9 @@ tr > td:nth-child(5) { width: 15%; } | Agilent (BioTek) | Cytation 1 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/cytation.html) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200) | | Agilent (BioTek) | Cytation 5 | absorbancefluorescenceluminescencemicroscopy | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/cytation.html) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) | | Agilent (BioTek) | Synergy H1 | absorbancefluorescenceluminescence | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/synergyh1.html) / [OEM](https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193) | -| Byonoy | Absorbance 96 Automate | absorbance | WIP | [OEM](https://byonoy.com/absorbance-96-automate/) | -| Byonoy | Luminescence 96 Automate | luminescence | WIP | [OEM](https://byonoy.com/luminescence-96-automate/) | +| Byonoy | Absorbance 96 Automate | absorbance | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/byonoy/absorbance.html) / [OEM](https://byonoy.com/absorbance-96-automate/) | +| Byonoy | Luminescence 96 | luminescence | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/byonoy/luminescence.html) / [OEM](https://byonoy.com/luminescence-96/) | +| Byonoy | Luminescence 96 Automate | luminescence | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/byonoy/luminescence.html) / [OEM](https://byonoy.com/luminescence-96-automate/) | | Molecular Devices | SpectraMax M5e | absorbancefluorescence time-resolved fluorescencefluorescence polarization | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) | | Molecular Devices | SpectraMax 384plus | absorbance | Full | [OEM](https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) | diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 5c68f4a1ddd..e3b96012ca1 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -2055,6 +2055,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 @@ -2404,7 +2407,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 58e3f7fa164..1ac68fc7b75 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -11,6 +11,10 @@ SynergyH1Backend, ) from .bmg_labtech import CLARIOstarBackend +from .byonoy import ( + ByonoyAbsorbance96AutomateBackend, + ByonoyLuminescence96AutomateBackend, +) from .chatterbox import PlateReaderChatterboxBackend from .image_reader import ImageReader from .imager import Imager diff --git a/pylabrobot/plate_reading/byonoy/__init__.py b/pylabrobot/plate_reading/byonoy/__init__.py new file mode 100644 index 00000000000..9779834c3e9 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/__init__.py @@ -0,0 +1,20 @@ +from .byonoy_a96a import ( + byonoy_a96a, + byonoy_a96a_detection_unit, + byonoy_a96a_illumination_unit, + byonoy_a96a_parking_unit, + byonoy_sbs_adapter, +) +from .byonoy_backend import ByonoyAbsorbance96AutomateBackend, ByonoyLuminescence96AutomateBackend +from .byonoy_l96 import ( + ByonoyLuminescence96Automate, + ByonoyLuminescenceBaseUnit, + byonoy_l96, + byonoy_l96_base_unit, + byonoy_l96_reader_unit, +) +from .byonoy_l96a import ( + byonoy_l96a, + byonoy_l96a_base_unit, + byonoy_l96a_reader_unit, +) diff --git a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/plate_reading/byonoy/byonoy_a96a.py new file mode 100644 index 00000000000..f30252e501d --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_a96a.py @@ -0,0 +1,188 @@ +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 +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.rotation import Rotation + + +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, + ), + ) + + +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 = 0, + child_location: Coordinate = Coordinate.zero(), + category: str = "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["ByonoyAbsorbanceBaseUnit"] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyAbsorbanceBaseUnit. This should not happen." + ) + + if self._byonoy_base.illumination_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while illumination unit is on the base. " + "Please remove the illumination unit from the base before dropping a resource." + ) + + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyAbsorbanceBaseUnit(Resource): + def __init__( + self, + name: str, + size_x: float = 155.26, + size_y: float = 95.48, + size_z: float = 18.5, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + ) + + self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder( + name=self.name + "_plate_holder", + size_x=127.76, # standard SBS footprint + size_y=85.59, + size_z=0, + child_location=Coordinate(x=22.5, y=5.0, z=16.0), + pedestal_size_z=0, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.illumination_unit_holder = ResourceHolder( + name=self.name + "_illumination_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=Coordinate(x=0, y=0, z=14.1), + ) + self.assign_child_resource(self.illumination_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + 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 + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or illumination_unit_holder to assign plates and the illumination unit, respectively." + ) + + +class ByonoyAbsorbance96Automate(PlateReader, ByonoyAbsorbanceBaseUnit): + def __init__(self, name: str): + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + PlateReader.__init__( + self, + name=name + "_reader", + size_x=138, + size_y=95.7, + size_z=0, + backend=ByonoyAbsorbance96AutomateBackend(), + ) + + +def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96Automate: + """Create a Byonoy A96A detection unit `PlateReader`. + + The detection unit is modeled as a fixed-size rectangular prism. + """ + + return ByonoyAbsorbance96Automate(name=name) + + +def byonoy_a96a_parking_unit(name: str) -> ByonoyAbsorbanceBaseUnit: + """Create a Byonoy A96A detection unit holder.""" + + return ByonoyAbsorbanceBaseUnit(name=name) + + +def byonoy_a96a_illumination_unit(name: str) -> Resource: + """ """ + size_x = 155.26 + size_y = 95.48 + return Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=42.898, + model="Byonoy A96A Illumination Unit", + preferred_pickup_location=Coordinate(x=size_x / 2, y=size_y / 2, z=29.5), + ) + + +def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96Automate, Resource]: + """Creates a ByonoyBase and a PlateReader instance.""" + reader = byonoy_a96a_detection_unit( + name=name + "_reader", + ) + illumination_unit = byonoy_a96a_illumination_unit( + name=name + "_illumination_unit", + ) + if assign: + reader.illumination_unit_holder.assign_child_resource(illumination_unit) + return reader, illumination_unit diff --git a/pylabrobot/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/plate_reading/byonoy/byonoy_backend.py new file mode 100644 index 00000000000..743d2c70cd8 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_backend.py @@ -0,0 +1,401 @@ +import abc +import asyncio +import enum +import threading +import time +from typing import Dict, List, Optional + +from pylabrobot.io.binary import Reader, Writer +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: bytes, routing_info: bytes) -> bytes: + packet = Writer().u16(report_id).raw_bytes(payload).finish() + packet += b"\x00" * (62 - len(packet)) + routing_info # pad to 64 bytes + return packet + + async def send_command( + self, + report_id: int, + payload: bytes, + wait_for_response: bool = True, + routing_info: bytes = b"\x00\x00", + ) -> Optional[bytes]: + command = self._assemble_command(report_id, payload=payload, routing_info=routing_info) + + await self.io.write(command) + if not wait_for_response: + return None + + 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 = Reader(response).u16() + if report_id == response_report_id: + break + return response + + def _background_ping_worker(self) -> 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. don't use send_command + payload = Writer().u8(1).finish() + cmd = self._assemble_command( + report_id=0x0040, # command id: HEARTBEAT_IN + payload=payload, + routing_info=b"\x00\x00", + ) + await self.io.write(cmd) + + 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() + + async def get_available_absorbance_wavelengths(self) -> List[float]: + response = await self.send_command( + report_id=0x0330, + payload=b"\x00" * 60, # 30 x i16 + wait_for_response=True, + routing_info=b"\x80\x40", + ) + assert response is not None, "Failed to get available wavelengths." + + # Skip the first 2 bytes (report_id), then read 30 signed 16-bit integers + reader = Reader(response[2:]) + available_wavelengths = [reader.i16() for _ in range(30)] + return [w for w in available_wavelengths if w != 0] + + 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=b"\x00" * 60, # seq, seq_len, ids[29] + wait_for_response=False, + ) + + # (2) DEVICE_DATA_READ_IN (0x0200) + payload2 = ( + Writer() + .u16(7) # field_index + .u8(0) # flags + .raw_bytes(b"\x00" * 52) # data + .finish() + ) + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + # (3) ABS_TRIGGER_MEASUREMENT_OUT (0x0320) + payload3 = ( + Writer() + .i16(signal_wl) + .i16(reference_wl) + .u8(int(is_reference)) + .u8(0) # flags + .finish() + ) + await self.send_command( + report_id=0x0320, + payload=payload3, + wait_for_response=False, + routing_info=b"\x00\x40", + ) + + # (4) Collect chunks (report_id 0x0500) + rows: List[float] = [] + t0 = time.time() + + while True: + if time.time() - t0 > 120: + raise TimeoutError("Measurement timeout.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + # Only handle the measurement packets + if report_id == 0x0500: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.i16() # signal_wl_nm + _ = reader.i16() # reference_wl_nm + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + rows.extend(row) + + if seq == seq_len - 1: + break + + return rows + + async def initialize_measurements(self): + """Perform the reference ABS measurement required by the firmware.""" + + # Standard reference wavelength used by Byonoy app + # required startup protocol to initialize the photodiode reference + REFERENCE_WL = 0 + SIGNAL_WL = 660 + + await self._run_abs_measurement( + signal_wl=SIGNAL_WL, + reference_wl=REFERENCE_WL, + is_reference=True, + ) + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + ) -> 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. + """ + + assert ( + wavelength in self.available_wavelengths + ), f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." + + rows = await self._run_abs_measurement( + signal_wl=wavelength, + reference_wl=0, + is_reference=False, + ) + + matrix = reshape_2d(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""" + + # SUPPORTED_REPORTS_IN (0x0010) + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, # seq, seq_len, ids[29] + wait_for_response=False, + ) + + # DEVICE_DATA_READ_IN (0x0200) + payload2 = ( + Writer() + .u16(7) # field_index + .u8(0) # flags + .raw_bytes(b"\x00" * 52) # data + .finish() + ) + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + # LUM_TRIGGER_MEASUREMENT_OUT (0x0340) + payload3 = ( + Writer() + .i32(int(integration_time * 1000 * 1000)) # integration_time_us + .raw_bytes(b"\xff" * 12) # channels_selected + .u8(0) # is_reference_measurement + .u8(0) # flags + .finish() + ) + await self.send_command( + report_id=0x0340, + payload=payload3, + wait_for_response=False, + ) + + t0 = time.time() + all_rows: List[float] = [] + + 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.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0600: # REP_LUM96_MEASUREMENT_IN + seq = reader.u8() + seq_len = reader.u8() + _ = reader.u32() # integration_time_us + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + all_rows.extend(row) + + if seq == seq_len - 1: + break + + hybrid_result = all_rows[96 * 0 : 96 * 1] + _ = all_rows[96 * 1 : 96 * 2] # counting_result + _ = all_rows[96 * 2 : 96 * 3] # sampling_result + _ = all_rows[96 * 3 : 96 * 4] # micro_counting_result + _ = all_rows[96 * 4 : 96 * 5] # micro_integration_result + _ = all_rows[96 * 5 : 96 * 6] # repetition_count + _ = all_rows[96 * 6 : 96 * 7] # integration_times + _ = all_rows[96 * 7 : 96 * 8] # below_breakdown_measurement + + return [ + { + "time": time.time(), + "temperature": None, + "data": reshape_2d(hybrid_result, (8, 12)), + } + ] + + async def read_fluorescence( + self, + plate: Plate, + wells, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + raise NotImplementedError("Fluorescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/plate_reading/byonoy/byonoy_l96.py new file mode 100644 index 00000000000..c9b080c5b33 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_l96.py @@ -0,0 +1,176 @@ +from typing import Optional, Tuple + +from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyLuminescence96AutomateBackend +from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.rotation import Rotation + + +class _ByonoyLuminescenceReaderPlateHolder(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, + child_location: Coordinate = Coordinate.zero(), + category: str = "plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=127.76, + size_y=85.59, + size_z=0, + pedestal_size_z=0, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional["ByonoyLuminescenceBaseUnit"] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyLuminescenceBaseUnit. This should not happen." + ) + + if self._byonoy_base.reader_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while reader unit is on the base. " + "Please remove the reader unit from the base before dropping a resource." + ) + + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyLuminescenceBaseUnit(Resource): + """Base unit for the Byonoy L96/L96A luminescence reader. + + The base unit is a simple resource that holds a plate. The reader unit sits on top of it. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + plate_holder_child_location: Coordinate, + reader_unit_holder_child_location: Coordinate, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + barcode: Optional[Barcode] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + ) + + self.plate_holder = _ByonoyLuminescenceReaderPlateHolder( + name=self.name + "_plate_holder", + child_location=plate_holder_child_location, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.reader_unit_holder = ResourceHolder( + name=self.name + "_reader_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=reader_unit_holder_child_location, + ) + self.assign_child_resource(self.reader_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + if isinstance(resource, _ByonoyLuminescenceReaderPlateHolder): + 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 + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, respectively." + ) + + +class ByonoyLuminescence96Automate(PlateReader): + """Byonoy L96/L96A luminescence plate reader unit. + + This is the reader unit that sits on top of the base unit. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + backend=ByonoyLuminescence96AutomateBackend(), + model="Byonoy L96 Reader Unit", + preferred_pickup_location=preferred_pickup_location, + ) + + +def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96Automate: + """Create a Byonoy L96 reader unit `PlateReader`. + + Note: L96 (non-automate) does not have a preferred pickup location. + """ + return ByonoyLuminescence96Automate( + name=name, + size_x=139.7, # caliper + size_y=97.5, # caliper + size_z=35, # force z probing + preferred_pickup_location=None, + ) + + +def byonoy_l96_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96 base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=139.7, # caliper + size_y=97.5, # caliper + size_z=9.4, # force z probing + plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), # caliper + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), # z = 42.2 - 35 + ) + + +def byonoy_l96( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: + """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96 (non-automate). + + Args: + name: Base name for the resources. + assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. + + Returns: + A tuple of (base_unit, reader_unit). + """ + base_unit = byonoy_l96_base_unit(name=name + "_base") + reader_unit = byonoy_l96_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py b/pylabrobot/plate_reading/byonoy/byonoy_l96a.py new file mode 100644 index 00000000000..56b2fe3d029 --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_l96a.py @@ -0,0 +1,50 @@ +from typing import Tuple + +from pylabrobot.resources import Coordinate + +from .byonoy_l96 import ( + ByonoyLuminescence96Automate, + ByonoyLuminescenceBaseUnit, +) + + +def byonoy_l96a_reader_unit(name: str) -> ByonoyLuminescence96Automate: + """Create a Byonoy L96A reader unit `PlateReader`.""" + return ByonoyLuminescence96Automate( + name=name, + size_x=138, # caliper + size_y=97.5, # caliper + size_z=41.7, # force z probing + preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), # z = 41.7 - 8.5 + ) + + +def byonoy_l96a_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96A base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=138, # caliper + size_y=97.5, # caliper + size_z=10.7, # force z probing + plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), # caliper + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), # z = 48 - 41.7 + ) + + +def byonoy_l96a( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: + """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96A (automate). + + Args: + name: Base name for the resources. + assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. + + Returns: + A tuple of (base_unit, reader_unit). + """ + base_unit = byonoy_l96a_base_unit(name=name + "_base") + reader_unit = byonoy_l96a_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit diff --git a/pylabrobot/plate_reading/byonoy/byonoy_tests.py b/pylabrobot/plate_reading/byonoy/byonoy_tests.py new file mode 100644 index 00000000000..06cd42c868a --- /dev/null +++ b/pylabrobot/plate_reading/byonoy/byonoy_tests.py @@ -0,0 +1,50 @@ +import unittest +import unittest.mock + +from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend +from pylabrobot.plate_reading.byonoy import ( + byonoy_a96a, + byonoy_sbs_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.reader, self.illumination_unit = byonoy_a96a(name="byonoy_test", assign=True) + self.adapter = byonoy_sbs_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.reader) + self.plate_carrier[2] = self.plate = CellVis_96_wellplate_350uL_Fb(name="plate") + + async def test_move_illumination_unit_to_reader(self): + # move illumination unit to deck + await self.lh.move_resource(self.illumination_unit, to=Coordinate(x=400, y=209.995, z=100)) + + # move illumination unit to reader + await self.lh.move_resource( + self.illumination_unit, + self.reader.illumination_unit_holder, + pickup_distance_from_top=7.45, + ) + assert self.illumination_unit.get_absolute_location() == Coordinate(x=697.85, y=162.2, z=213.2) + + async def test_move_plate_to_reader(self): + self.illumination_unit.unassign() + await self.lh.move_resource( + self.plate, + self.reader.plate_holder, + ) + assert self.plate.get_absolute_location() == Coordinate(x=720.35, y=167.2, z=215.1) + + async def test_move_plate_to_reader_when_illumination_unit_present(self): + with self.assertRaises(RuntimeError): + await self.lh.move_resource( + self.plate, + self.reader.plate_holder, + ) diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index fc78b40a83a..37cf60ddc7e 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -32,8 +32,10 @@ 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(), + preferred_pickup_location: Optional[Coordinate] = None, ) -> None: ResourceHolder.__init__( self, @@ -43,6 +45,8 @@ def __init__( size_z=size_z, category=category, model=model, + child_location=child_location, + preferred_pickup_location=preferred_pickup_location, ) Machine.__init__(self, backend=backend) self.backend: PlateReaderBackend = backend # fix type @@ -53,17 +57,16 @@ def assign_child_resource( location: Optional[Coordinate] = None, reassign: bool = True, ): - if len(self.children) >= 1: + if len([c for c in self.children if isinstance(c, Plate)]) >= 1: raise ValueError("There already is a plate in the plate reader.") - if not isinstance(resource, Plate): - raise ValueError("The resource must be a Plate.") super().assign_child_resource(resource, location=location, reassign=reassign) def get_plate(self) -> Plate: - if len(self.children) == 0: + plate_children = [c for c in self.children if isinstance(c, Plate)] + if len(plate_children) == 0: raise NoPlateError("There is no plate in the plate reader.") - return cast(Plate, self.children[0]) + return cast(Plate, plate_children[0]) @need_setup_finished async def open(self, **backend_kwargs) -> None: @@ -187,7 +190,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 +197,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..51923c65ed7 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, *, reassign: bool = True) -> 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 dd7d38beb73..29986d97651 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -255,7 +255,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( @@ -950,6 +950,9 @@ def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = T # (delegated to `_check_assignment` to stay consistent as rules evolve). self._check_assignment(resource=resource, reassign=reassign) + # Tree-wide invariants enforced at the root (e.g., global naming constraints). + # self.get_root()._check_naming_conflicts(resource=resource) + # Subclasses can add stricter “drop rules” here. # Examples: # - Enforce placement/geometry constraints (must fit, no overlaps, valid coordinates) diff --git a/pylabrobot/resources/resource_holder.py b/pylabrobot/resources/resource_holder.py index 315c001da73..c6bc64151f6 100644 --- a/pylabrobot/resources/resource_holder.py +++ b/pylabrobot/resources/resource_holder.py @@ -41,8 +41,18 @@ def __init__( category="resource_holder", model=None, child_location: Coordinate = Coordinate.zero(), + preferred_pickup_location: Optional[Coordinate] = None, ): - super().__init__(name, size_x, size_y, size_z, rotation, category, model) + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + preferred_pickup_location=preferred_pickup_location, + ) self.child_location = child_location def get_default_child_location(self, resource: Resource) -> Coordinate: @@ -76,3 +86,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, *, reassign: bool = True) -> 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..b66b4b87632 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, *, reassign: bool = True) -> None: + # for now, any resource can be dropped onto a stack. + pass