Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
{
"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 and a heartbeat thread is running.\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": "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, Lum96IntegrationMode\n",
"\n",
"# Preset\n",
"results = await reader.luminescence.read(\n",
" plate=plate,\n",
" focal_height=0,\n",
" backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n",
" mode=Lum96IntegrationMode.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 96-bool mask in plate row-major order (A1 = index 0, A12 = 11, B1 = 12, ..., H12 = 95). Unselected wells come back as exactly `0.0`. The result shape is still 8×12 — it's an output filter, not a different report.\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). Use `selected_wells` to keep your downstream tidy, not to 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",
"mask = [False] * 96\n",
"for row in range(8):\n",
" mask[row * 12 + 0] = True\n",
"\n",
"results = await reader.luminescence.read(\n",
" plate=plate,\n",
" focal_height=0,\n",
" backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n",
" selected_wells=mask,\n",
" ),\n",
")"
]},
{"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=Lum96IntegrationMode.ULTRA_SENSITIVE,\n",
" ),\n",
" )\n",
")\n",
"await asyncio.sleep(1.0)\n",
"await reader.driver.cancel(report_id=0x0340)\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.get_status()\n",
"env = await reader.driver.get_environment()\n",
"info = await reader.driver.get_device_info()\n",
"versions = await reader.driver.get_versions()\n",
"api = await reader.driver.get_api_version()\n",
"supported = await reader.driver.get_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.version_no}, 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 RGB front bar. Useful in a workcell to flag run state. Available effects: `SOLID`, `BLINKING`, `BREATHING`, `CYLON`, `RAINBOW`, `PROGRESS`. The visual rendering of dynamic effects is firmware-defined; `set_led_colours` is the precise way to control exactly what you see."
]},
{"cell_type": "code", "execution_count": null, "id": "s10-code", "metadata": {}, "outputs": [], "source": [
"from pylabrobot.byonoy import LedEffect\n",
"\n",
"# Solid colour — auto-enables manual mode\n",
"await reader.driver.set_led_colours([(255, 200, 0)] * 20) # amber: queued\n",
"await reader.driver.set_led_colours([(0, 255, 0)] * 20) # green: ready\n",
"await reader.driver.set_led_colours([(255, 0, 0)] * 20) # red: error\n",
"\n",
"# Built-in firmware effects\n",
"await reader.driver.set_led_effect(LedEffect.BREATHING, duration_ms=10000)\n",
"await reader.driver.set_led_effect(LedEffect.SOLID, duration_ms=0) # back to default"
]},
{"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 (\n",
" byonoy_l96, ByonoyLuminescence96Backend,\n",
" Lum96IntegrationMode, LedEffect,\n",
")\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_colours([(255, 150, 0)] * 20) # amber: prep\n",
"\n",
"# Sanity check\n",
"status = await reader.driver.get_status()\n",
"info = await reader.driver.get_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_colours([(0, 255, 0)] * 20)\n",
"results = await reader.luminescence.read(\n",
" plate=plate,\n",
" focal_height=0,\n",
" backend_params=ByonoyLuminescence96Backend.LuminescenceParams(\n",
" mode=Lum96IntegrationMode.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_effect(LedEffect.SOLID, duration_ms=0)\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()` doesn't abort | Wrong report id | Default `0x0340` is the lum trigger; usually correct |\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.\n",
"- **Source**: `pylabrobot/byonoy/backend.py` (transport + queries), `pylabrobot/byonoy/luminescence_96.py` (lum read).\n",
"- **Vendor library**: `byonoy_device_library` (C with pybind11 wrapper). Not a runtime dependency for PLR — every byte is decoded from the headers and goes through PLR's own HID transport.\n",
"- **Companion notebook**: `hello-world.ipynb` for a minimal run-through."
]}
],
"metadata": {
"kernelspec": {"display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3"},
"language_info": {"name": "python", "version": "3.11.0"}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading
Loading