{ "cells": [ { "cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ "# Byonoy Luminescence 96 — lab guide\n", "\n", "Run a luminescence assay on the Byonoy L96 from PyLabRobot. Assumes the device is plugged in via USB and you've installed `pylabrobot` (with `hid` and `hidapi`).\n", "\n", "The L96 is a 96-well luminescence-only plate reader. It reads emitted light per well, no excitation source. Communicates over USB HID (vid `0x16D0`, pid `0x119B`)." ] }, { "cell_type": "markdown", "id": "s1-md", "metadata": {}, "source": [ "## 1. Connect\n", "\n", "`base` is a resource (a plate goes here); `reader` is both a resource and a device (the detector unit). After `setup()` the HID handle is open.\n", "\n", "> **One process at a time.** macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without `stop()`, the next `setup()` will fail with \"device already open\". Replug the USB cable to force-release." ] }, { "cell_type": "code", "execution_count": null, "id": "s1-code", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant\n", "\n", "base, reader = byonoy_l96(name=\"l96\")\n", "await reader.setup()" ] }, { "cell_type": "markdown", "id": "05b48622", "metadata": {}, "source": [ "## Resource layout\n", "\n", "The reader unit is both a `Resource` and a `Device`. The base unit owns two child holders, and the interlock lives on the plate holder:\n", "\n", "```\n", "ByonoyLuminescenceBaseUnit (base)\n", " +-- plate_holder (assign plates here)\n", " +-- reader_unit_holder (reader unit sits here during measurement)\n", "```" ] }, { "cell_type": "markdown", "id": "s2-md", "metadata": {}, "source": [ "## 2. Load a plate\n", "\n", "The reader-on-base interlock prevents you from assigning a plate while the detector is still on the holder — it forces a sane physical sequence.\n", "\n", "After running this cell, physically place the plate in the reader and place the detector back on top." ] }, { "cell_type": "code", "execution_count": null, "id": "s2-code", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", "\n", "base.reader_unit_holder.unassign_child_resource(reader) # take detector off\n", "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", "base.plate_holder.assign_child_resource(plate)" ] }, { "cell_type": "markdown", "id": "s3-md", "metadata": {}, "source": [ "## 3. Read — the basics\n", "\n", "`focal_height` is required by the abstract `Luminescence` capability but **ignored by the L96** — the device has a fixed optical configuration (the detector unit clamps onto the base; geometry is determined by plate + base + detector heights, not user-tunable). Pass `0` by convention.\n", "\n", "### Result shape\n", "\n", "`data` is plate row-major: `data[0]` = `[A1..A12]`, `data[1]` = `[B1..B12]`, ..., `data[7]` = `[H1..H12]`. So `data[2][5]` is well `C6`. Values are floats in **RLU (Relative Light Units) per integration period** — not per second. Doubling integration time roughly doubles signal *and* dark counts.\n", "\n", "### Background\n", "\n", "With nothing in the wells (and a dark environment), expect noise around ±50 RLU at SENSITIVE (2 s). Non-zero noise is dark-current spread; **negative values are normal** because the firmware applies a baseline subtraction.\n", "\n", "> **Light leakage.** The L96 is designed to be light-tight from above (the detector unit covers the plate) but the bottom housing isn't perfectly sealed. Reading on a *white* surface vs a *black* surface can change empty-well readings from ~50 to ~50,000+ RLU because reflected ambient light leaks in. For real assays use a black mat or a dark cabinet." ] }, { "cell_type": "code", "execution_count": null, "id": "s3-code", "metadata": {}, "outputs": [], "source": [ "results = await reader.luminescence.read(plate=plate, focal_height=0)\n", "data = results[0].data # 8 × 12 list[list[float]]\n", "timestamp = results[0].timestamp # epoch seconds\n", "\n", "print(f\"timestamp={timestamp}\")\n", "for row in data:\n", " print(\" \" + \" \".join(f\"{v:8.2f}\" for v in row))" ] }, { "cell_type": "markdown", "id": "s4-md", "metadata": {}, "source": [ "## 4. Picking an integration mode\n", "\n", "Four modes, mapping to the byonoy_device_library presets:\n", "\n", "| Mode | Integration time | Use for |\n", "|---|---|---|\n", "| `RAPID` | 100 ms | Saturation checks, quick \"is it bright?\" |\n", "| `SENSITIVE` | 2 s (default) | Most assays — luciferase, BRET, NanoBiT |\n", "| `ULTRA_SENSITIVE` | 20 s | Very faint signals; low-copy reporters |\n", "| `CUSTOM` | user-supplied | Your own duration |" ] }, { "cell_type": "code", "execution_count": null, "id": "s4-code", "metadata": {}, "outputs": [], "source": [ "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n", "\n", "# Preset\n", "results = await reader.luminescence.read(\n", " plate=plate,\n", " focal_height=0,\n", " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", " mode=\"ultra_sensitive\",\n", " ),\n", ")\n", "\n", "# Custom (any duration in seconds) — auto-switches to CUSTOM mode\n", "results = await reader.luminescence.read(\n", " plate=plate,\n", " focal_height=0,\n", " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", " integration_time=5.0,\n", " ),\n", ")" ] }, { "cell_type": "markdown", "id": "s5-md", "metadata": {}, "source": [ "## 5. Reading specific wells\n", "\n", "Pass a `wells=` list to `read()` — only those wells get real values back; everything else comes back as `None`. The result shape is still 8×12 (per the `LuminescenceResult` contract); unmeasured cells are `None` so you can distinguish \"didn't read\" from a legitimate `0.0` reading (baseline subtraction can yield ~0 or negative values).\n", "\n", "> **No speed-up.** The firmware always integrates the whole 96-sensor array. Reading one column with `SENSITIVE` takes the same wall-clock as reading the full plate (~28 s in our hardware test). The `wells` filter keeps your downstream tidy — it doesn't save time. If you want fast, use `RAPID` mode." ] }, { "cell_type": "code", "execution_count": null, "id": "s5-code", "metadata": {}, "outputs": [], "source": [ "# Only column 1 (A1, B1, ..., H1)\n", "col1_wells = [plate.get_well(f\"{r}1\") for r in \"ABCDEFGH\"]\n", "\n", "results = await reader.luminescence.read(\n", " plate=plate,\n", " focal_height=0,\n", " wells=col1_wells,\n", ")\n", "# results[0].data[0][0] is A1 (float); results[0].data[0][1] is A2 (None)" ] }, { "cell_type": "markdown", "id": "s6-md", "metadata": {}, "source": [ "## 6. Timed read (delay before reading)\n", "\n", "For a substrate-injection assay where you want a fixed delay between adding reagent and reading. `await asyncio.sleep` doesn't block the event loop, and the reader stays connected." ] }, { "cell_type": "code", "execution_count": null, "id": "s6-code", "metadata": {}, "outputs": [], "source": [ "import asyncio\n", "\n", "# ... pipette substrate into the plate ...\n", "await asyncio.sleep(60) # 60 s incubation\n", "results = await reader.luminescence.read(plate=plate, focal_height=0)" ] }, { "cell_type": "markdown", "id": "s7-md", "metadata": {}, "source": [ "## 7. Kinetic read (time series)\n", "\n", "Read the same plate every N seconds, collect a stack of matrices. With `SENSITIVE` (2 s) the wall-clock per read is around 3 s including overhead, so `interval_s` must be ≥ 3. With `ULTRA_SENSITIVE` (20 s) it's around 28 s — plan accordingly." ] }, { "cell_type": "code", "execution_count": null, "id": "s7-code", "metadata": {}, "outputs": [], "source": [ "import asyncio, time\n", "import numpy as np\n", "\n", "frames = []\n", "duration_s = 600 # 10 minutes total\n", "interval_s = 30 # one read every 30 s\n", "\n", "t_start = time.time()\n", "while time.time() - t_start < duration_s:\n", " t_read = time.time()\n", " results = await reader.luminescence.read(plate=plate, focal_height=0)\n", " frames.append({\n", " \"t\": t_read - t_start,\n", " \"data\": results[0].data,\n", " })\n", " elapsed = time.time() - t_read\n", " if elapsed < interval_s:\n", " await asyncio.sleep(interval_s - elapsed)\n", "\n", "matrix_stack = np.array([f[\"data\"] for f in frames]) # (n_frames, 8, 12)\n", "times = np.array([f[\"t\"] for f in frames])\n", "print(f\"collected {len(frames)} frames over {duration_s} s\")\n", "# Trace for well C6:\n", "trace = matrix_stack[:, 2, 5]" ] }, { "cell_type": "markdown", "id": "s8-md", "metadata": {}, "source": [ "## 8. Stopping a long read\n", "\n", "If you need to bail out of an `ULTRA_SENSITIVE` read mid-flight (or any read takes longer than expected), `cancel()` raises a flag the read loop checks; bail-out is within ~2 s. After cancel the device stays initialised and immediately accepts new reads." ] }, { "cell_type": "code", "execution_count": null, "id": "s8-code", "metadata": {}, "outputs": [], "source": [ "task = asyncio.create_task(\n", " reader.luminescence.read(plate=plate, focal_height=0,\n", " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", " mode=\"ultra_sensitive\",\n", " ),\n", " )\n", ")\n", "await asyncio.sleep(1.0)\n", "await reader.driver.cancel()\n", "try:\n", " await task\n", "except asyncio.CancelledError:\n", " print(\"aborted cleanly\")" ] }, { "cell_type": "markdown", "id": "s9-md", "metadata": {}, "source": [ "## 9. Device health & identity\n", "\n", "Useful at the start of a session, in error messages, or for run logging.\n", "\n", "> **`slot_state`**: `OCCUPIED` when a plate is loaded, `UNKNOWN` when nothing is in the reader (the firmware can't tell empty from missing). Don't treat `UNKNOWN` as an error.\n", ">\n", "> **`error_code`**: `0` is `NO_ERROR`. The Lum96 firmware doesn't publish a documented table for non-zero values, so non-zero codes surface as `errorCode=0xNN` (matching what Byonoy's own C library returns). For Abs96 / AbsOne backends, names like `ERROR_CALIB` / `AMBIENT_LIGHT` are decoded automatically." ] }, { "cell_type": "code", "execution_count": null, "id": "s9-code", "metadata": {}, "outputs": [], "source": [ "status = await reader.driver.request_status()\n", "env = await reader.driver.request_environment()\n", "info = await reader.driver.request_device_info()\n", "versions = await reader.driver.request_versions()\n", "api = await reader.driver.request_api_version()\n", "supported = await reader.driver.request_supported_reports()\n", "\n", "print(f\"{info.device_name} sn={info.serial_no} fw={info.firmware_version}\")\n", "print(f\" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%\")\n", "print(f\" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}\")\n", "print(f\" api v{api}, fw production={versions.is_production}\")\n", "print(f\" supported reports ({len(supported)}): \" + \", \".join(f\"0x{i:04X}\" for i in supported))" ] }, { "cell_type": "markdown", "id": "s10-md", "metadata": {}, "source": [ "## 10. Visual feedback (LED bar)\n", "\n", "The L96 has a 20-pixel addressable RGB front bar. Useful in a workcell to flag run state — solid colors for status, firmware-driven animations (`BREATHING`, `CYLON`, etc.) for \"busy\" indicators, or per-pixel control for progress bars and custom animations.\n", "\n", "See the dedicated [LED bar notebook](led_bar.ipynb) for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below." ] }, { "cell_type": "code", "execution_count": null, "id": "s10-code", "metadata": {}, "outputs": [], "source": [ "await reader.driver.set_led_color((0, 255, 0)) # solid green: ready\n", "await reader.driver.set_led_color((0, 255, 0), \"breathing\", duration_ms=10000) # busy\n", "await reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split" ] }, { "cell_type": "markdown", "id": "s11-md", "metadata": {}, "source": [ "## 11. End-point luciferase recipe\n", "\n", "End-to-end workflow for a typical end-point luciferase assay." ] }, { "cell_type": "code", "execution_count": null, "id": "s11-code", "metadata": {}, "outputs": [], "source": [ "import asyncio, time\n", "import numpy as np\n", "from pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend\n", "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", "\n", "# Connect\n", "base, reader = byonoy_l96(name=\"assay\")\n", "await reader.setup()\n", "await reader.driver.set_led_color((255, 150, 0)) # amber: prep\n", "\n", "# Sanity check\n", "status = await reader.driver.request_status()\n", "info = await reader.driver.request_device_info()\n", "print(f\"{info.device_name} sn={info.serial_no} — {status.slot_state.name}\")\n", "assert status.error_code == 0\n", "\n", "# Load plate\n", "base.reader_unit_holder.unassign_child_resource(reader)\n", "plate = Cor_96_wellplate_360ul_Fb(name=\"assay_plate\")\n", "base.plate_holder.assign_child_resource(plate)\n", "# (operator places plate, places detector back on top)\n", "\n", "# Read — green while measuring\n", "await reader.driver.set_led_color((0, 255, 0))\n", "results = await reader.luminescence.read(\n", " plate=plate,\n", " focal_height=0,\n", " backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n", " mode=\"sensitive\",\n", " ),\n", ")\n", "data = np.array(results[0].data) # 8 × 12\n", "\n", "# Save + tidy up\n", "np.save(f\"luminescence_{int(time.time())}.npy\", data)\n", "await reader.driver.set_led_color((0, 0, 0)) # off\n", "await reader.stop()" ] }, { "cell_type": "markdown", "id": "s12-md", "metadata": {}, "source": [ "## 12. Troubleshooting\n", "\n", "| Symptom | Likely cause | Fix |\n", "|---|---|---|\n", "| `setup()` raises \"device already open\" | Previous Python session left HID handle locked | Replug USB cable, or kill stale Python processes |\n", "| All wells read very high (10⁴–10⁶) with no sample | Light leak through housing bottom | Use a dark mat or close the room |\n", "| Strong gradient A→H with no sample | Directional light leak (one side leakier) | Same — dark mat |\n", "| `slot_state=UNKNOWN` | No plate loaded | Expected — firmware cannot detect \"nothing\" definitively |\n", "| Read takes 28 s for one well in SENSITIVE | Firmware always integrates 96 wells | Use `RAPID` for fast, accept the 28 s for SENSITIVE |\n", "| `cancel()` returns immediately, read keeps going | No measurement in flight | `cancel()` auto-detects the in-flight trigger; no-op if there isn't one |\n", "| Negative readings on empty wells | Firmware baseline subtraction | Expected — they should sit around zero |\n", "| `focal_height` parameter has no effect | L96 has fixed optics | Expected — parameter is ABC contract, ignored on this device |" ] }, { "cell_type": "markdown", "id": "s13-md", "metadata": {}, "source": [ "## 13. Reference\n", "\n", "- **Hardware protocol**: HID 64-byte frames, vid `0x16D0`, pid `0x119B`. Report IDs decoded from Byonoy's `byonoyusbhid.h`. `routing_info=\\x80\\x40` requests a reply; `\\x00\\x00` is fire-and-forget." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11.0" } }, "nbformat": 4, "nbformat_minor": 5 }