diff --git a/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb new file mode 100644 index 00000000000..f7f09e8495e --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/lab_guide.ipynb @@ -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 +} diff --git a/pylabrobot/byonoy/ARCHITECTURE_NOTES.md b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md new file mode 100644 index 00000000000..58b98a087e5 --- /dev/null +++ b/pylabrobot/byonoy/ARCHITECTURE_NOTES.md @@ -0,0 +1,160 @@ +# Byonoy package — architecture notes for future refactors + +These notes capture the v1b1-capability review results from the +`byonoy-luminescence` branch (12 commits, HEAD `d28c0aebe`) so the +context is preserved for whoever next reorganises this module. They +are advisory — the package works as-is and ships in v1b1. + +## Pre-existing structural divergence from canonical v1b1 + +The pre-existing `ByonoyBase` (inherited from `upstream/v1b1`) collapses +the `Driver` and `CapabilityBackend` layers into one class: + +``` +ByonoyBase(Driver, metaclass=ABCMeta) # acts as both Driver + base for Backends + └─ ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend) + └─ ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend) +``` + +`ByonoyLuminescence96Backend` is therefore *both* a `Driver` and a +`LuminescenceBackend`. Compared to canonical v1b1: + +- **P-06 (four-layer architecture)**: not separated — the `Driver` and + `CapabilityBackend` are fused. +- **P-05 (backend stores `_driver` reference)**: not applicable — the + backend *is* the driver. +- **P-08 (`Driver` naming)**: `ByonoyBase` does not + follow the convention. v1b1 precedent: `BioShakeDriver`, + `NimbusDriver`, `XArm6Driver`, `STARDriver`, `TecanInfiniteDriver`. +- **P-25 (lifecycle hook scope)**: capability-specific init lives in + `setup` instead of `_on_setup` (visible in + `ByonoyAbsorbance96Backend.setup`, which calls + `initialize_measurements` and `request_available_absorbance_wavelengths` + inside the driver-level `setup`). Pre-existing in upstream/v1b1. + +When a future PR refactors: + +1. Introduce `class ByonoyDriver(Driver)` carrying the HID transport, + heartbeat thread, `send_command`, the device-info methods, the + abort flag, and the LED operations. +2. Make `ByonoyLuminescence96Backend(LuminescenceBackend)` a plain + `CapabilityBackend` that takes a `driver: ByonoyDriver` in + `__init__` and stores it as `self._driver`. +3. Move capability-specific work (the abs96 wavelength discovery, + `initialize_measurements`) from `setup` into `_on_setup`. +4. The Device class stays at `ByonoyLuminescence96(Resource, Device)` + and constructs the driver + backend separately, then wires + `_capabilities = [self.luminescence]`. v1b1 precedent for the + driver-shared-across-multiple-backends shape: + `pylabrobot/tecan/infinite/infinite.py:31-75` — `TecanInfinite200Pro` + wires `Absorbance`, `Fluorescence`, `Luminescence`, `LoadingTray` + backends onto a single `TecanInfiniteDriver`. + +## Findings introduced by the `byonoy-luminescence` branch + +### F1 — LED control could be a P-16 helper subsystem (soft) + +`set_led_colours` and `set_led_effect` live as flat methods on the +`Driver`. They form a coherent subsystem (touch reports 0x0350 / 0x0351, +share manual-mode coordination — `set_led_colours` already chains an +effect-set + colour-write). v1b1 precedent: `STARCover`, +`STARWashStation`, `NimbusDoor` group related operations into a plain +helper class attached as a Driver attribute, with `_on_setup` / +`_on_stop` hooks. + +Suggested shape: + +```python +class ByonoyLEDBar: + """Plain helper class (not a CapabilityBackend), following the + STARCover pattern. Drives the 20-pixel front bar.""" + def __init__(self, driver: ByonoyDriver) -> None: + self._driver = driver + async def _on_setup(self) -> None: pass + async def _on_stop(self) -> None: pass + async def set_colours(self, colours: List[Tuple[int, int, int]]) -> None: ... + async def set_effect(self, effect: LedEffect, ...) -> None: ... +``` + +User call site changes from `reader.driver.set_led_colours(...)` to +`reader.driver.led_bar.set_colours(...)`. + +### F2 — Device-info queries could be a P-16 helper subsystem (soft) + +Eight related methods on the `Driver` (`get_status`, `get_environment`, +`get_versions`, `get_api_version`, `get_supported_reports`, +`read_data_field`, `get_device_info`, `describe_error_code`) plus a +class-attribute extension hook (`_ERROR_NAMES`). The override is +currently per-backend-subclass (`ByonoyAbsorbance96Backend._ERROR_NAMES += ABS96_ERROR_NAMES`); a helper class would localise the override +surface alongside the methods that consume it. + +Suggested shape: + +```python +class ByonoyDiagnostics: + """Plain helper class (not a CapabilityBackend), following the + STARCover pattern. Reads device metadata and decodes firmware + errors per the device's known table.""" + _ERROR_NAMES: Dict[int, str] = _GENERIC_ERROR_NAMES # override per device + + def __init__(self, driver: ByonoyDriver) -> None: + self._driver = driver + async def _on_setup(self) -> None: pass + async def _on_stop(self) -> None: pass + async def get_status(self) -> ByonoyStatus: ... + async def get_environment(self) -> ByonoyEnvironment: ... + # ... etc. + def describe_error_code(self, code: int) -> str: ... +``` + +Per-device subclasses (`Abs96Diagnostics(ByonoyDiagnostics)`) override +`_ERROR_NAMES`. The Driver constructs the right subclass per its +device type. + +### F3 — `LuminescenceParams` shape is correct (informational, positive) + +The new `mode` / `integration_time` / `selected_wells` fields on a +typed dataclass inheriting `BackendParams` match v1b1 idiom (P-22). +The integration-mode preset table (`LUM96_PRESET_S`) is co-located. +The `integration_time is not None → CUSTOM` resolution preserves the +legacy call shape. No change needed. + +### F4 — `_abort_requested` flag should propagate to abs96 (soft) + +Setting and consuming the abort flag works because the backend *is* +the driver (collapse). With a Driver/Backend split, the flag belongs +on the Driver so all backends see it. Until then: copy the +`if self._abort_requested: ... raise asyncio.CancelledError(...)` +guard from `luminescence_96.py` read loop into +`absorbance_96.py:_run_abs_measurement`'s read loop. Same shape; one +block; makes `cancel()` consistent across both backends. + +### F5 — `ByonoyBase` → `ByonoyDriver` rename (soft, out of scope) + +The `Base` suffix is non-idiomatic. Every v1b1 device driver is named +`Driver`. When the architectural split (above) happens, +rename to `ByonoyDriver`. The per-device pid is already passed via +`__init__`, so no signature change. + +## Why the divergences are tolerable today + +- The package works on real hardware (validated against an L96 with + serial `BYOMAL00029`). +- The collapse predates this branch — splitting it is independent + refactoring work. +- The user-visible API (`reader.luminescence.read(...)`, + `reader.driver.get_status()`) doesn't depend on the internal + layering and would survive a refactor unchanged for callers. +- Helper-subsystem grouping (F1, F2) changes call sites + (`driver.led_bar.set_colours` vs `driver.set_led_colours`); worth + doing in a single coordinated PR rather than piecemeal. + +## Reference + +- v1b1-capability skill review run: `2026-05-06` +- Patterns cited: P-05, P-06, P-08, P-13, P-16, P-19, P-22, P-25 from + `~/.claude/skills/v1b1-capability/reference.md` +- v1b1 helper precedent: `pylabrobot/hamilton/liquid_handlers/star/cover.py`, + `wash_station.py`, `x_arm.py`, `autoload.py`, and + `pylabrobot/hamilton/liquid_handlers/nimbus/door.py` diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py index c9289dff529..20d1dca32ba 100644 --- a/pylabrobot/byonoy/__init__.py +++ b/pylabrobot/byonoy/__init__.py @@ -1,3 +1,13 @@ +from .backend import ( + Abs1StatusError, + Abs96StatusError, + ByonoyDeviceInfo, + ByonoyEnvironment, + ByonoyStatus, + ByonoyVersions, + LedEffect, + Lum96IntegrationMode, +) from .absorbance_96 import ( ByonoyAbsorbance96, ByonoyAbsorbance96Backend, diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 1cc7f59c09a..7bea4fc5b32 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -2,7 +2,7 @@ import time from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.byonoy.backend import ABS96_ERROR_NAMES, ByonoyBase, ByonoyDevice from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import ( Absorbance, @@ -29,6 +29,8 @@ class ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend): """Backend for the Byonoy Absorbance 96 Automate plate reader.""" + _ERROR_NAMES = ABS96_ERROR_NAMES + def __init__(self) -> None: super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96) self.available_wavelengths: List[float] = [] diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 726e7509568..56d1af2e92a 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -4,7 +4,8 @@ import threading import time from abc import ABCMeta -from typing import Optional +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple from pylabrobot.capabilities.capability import BackendParams from pylabrobot.device import Driver @@ -19,9 +20,169 @@ class ByonoyDevice(enum.Enum): LUMINESCENCE_96 = enum.auto() +class ByonoySlotState(enum.IntEnum): + UNKNOWN = 0 + EMPTY = 1 + OCCUPIED = 2 + UNDETERMINED = 3 + + +class Lum96IntegrationMode(enum.Enum): + RAPID = "rapid" + SENSITIVE = "sensitive" + ULTRA_SENSITIVE = "ultra_sensitive" + CUSTOM = "custom" + + +# Preset integration times (matches byonoy_device_library: hidmeasurements.cpp) +LUM96_PRESET_S = { + Lum96IntegrationMode.RAPID: 0.1, + Lum96IntegrationMode.SENSITIVE: 2.0, + Lum96IntegrationMode.ULTRA_SENSITIVE: 20.0, +} + + +def encode_well_bitmask(selected: List[bool], n: int = 96) -> bytes: + """Pack a length-n bool list into a little-endian bitmask, LSB-first within each byte.""" + if len(selected) != n: + raise ValueError(f"expected {n} bools, got {len(selected)}") + nbytes = (n + 7) // 8 + out = bytearray(nbytes) + for i, b in enumerate(selected): + if b: + out[i // 8] |= 1 << (i % 8) + return bytes(out) + + +@dataclass +class ByonoyStatus: + is_initialized: bool + slot_state: ByonoySlotState + error_code: int + uptime_s: int + is_measuring: bool + boot_completed: bool + + +@dataclass +class ByonoyEnvironment: + temperature_c: float + humidity: float # 0..1 + acceleration_g: Tuple[float, float, float] + + +@dataclass +class ByonoyVersions: + system_version: int + stm_version: int + stm_dev_version: int + esp_version: int + esp_dev_version: int + stm_bootloader_version: int + + @property + def system_version_known(self) -> bool: + return self.system_version != 0 + + @property + def is_production(self) -> bool: + return self.stm_dev_version == 0 and self.esp_dev_version == 0 + + +@dataclass +class ByonoyApiVersion: + version_no: int + + +@dataclass +class ByonoyDeviceInfo: + device_id: str + device_name: str + manufacturer: str + serial_no: str + firmware_version: str + ref_number: str + + +# device_data_field_id (byonoyusbhid.h) +_DD_DEVICE_ID = 0 +_DD_DEVICE_NAME = 1 +_DD_DEVICE_MANUFACTURER = 2 +_DD_SERIAL_NO = 3 +_DD_FIRMWARE_VERSION = 4 +_DD_REF_NUMBER = 8 + +# device_data_field_flags (byonoyusbhid.h) +_FLAG_TYPE_MASK = 0x0F +_FLAG_TYPE_STRING = 0x02 +_FLAG_TYPE_INTEGER = 0x01 +_FLAG_TYPE_FLOAT = 0x04 +_FLAG_TYPE_BOOLEAN = 0x03 +_FLAG_HAS_MORE_DATA = 0x10 + + +class LedEffect(enum.IntEnum): + SOLID = 0x00 + PROGRESS = 0x01 + CYLON = 0x02 + RAINBOW = 0x03 + BLINKING = 0x04 + BREATHING = 0x05 + + +# --- Firmware error codes (per Byonoy hid-reports source) ------------------- +# +# The status_in_t.error_code byte is device-specific. Byonoy's own C library +# defines a Status base class that just stringifies the hex code, with per- +# device subclasses (Abs96Status, Abs1Status) providing named tables. There +# is no documented Lum96 table — Lum96 inherits the generic stringifier. +# +# These mirror the enums in: +# hid-reports/src/hid/report/request/abs96status.cpp +# hid-reports/src/hid/report/request/abs1status.cpp + + +class Abs96StatusError(enum.IntEnum): + NO_ERROR = 0 + ERROR_CALIB = 1 + ERROR_AMBIENT = 2 + ERROR_USB = 3 + ERROR_HARDWARE = 4 + ERROR_TEMPERATURE = 5 + ERROR_NO_MEASUREMENTUNIT = 6 + ERROR_NO_ACK = 10 + + +class Abs1StatusError(enum.IntFlag): + """AbsOne errors are a bit-flag set — multiple can be raised at once.""" + NO_ERROR = 0 + AMBIENT_LIGHT = 1 + MIN_LIGHT = 2 + USB = 4 + HARDWARE = 8 + EEPROM = 16 + TIMEOUT = 32 + POWER_CALIBRATION = 64 + NOISE_LIMIT = 128 + + +_GENERIC_ERROR_NAMES: Dict[int, str] = {0: "NO_ERROR"} +ABS96_ERROR_NAMES: Dict[int, str] = {e.value: e.name for e in Abs96StatusError} +ABS1_ERROR_NAMES: Dict[int, str] = {e.value: e.name for e in Abs1StatusError} + + +_ACCEL_LSB_PER_G = 16384.0 # 14-bit signed @ ±2 g full scale + + class ByonoyBase(Driver, metaclass=ABCMeta): """Shared HID communication logic for Byonoy plate readers.""" + # Firmware error-code → name mapping. Default mirrors Byonoy's generic + # Status::firmwareErrorId (only NO_ERROR is documented). Subclasses for + # specific devices (e.g. ByonoyAbsorbance96Backend) override with their + # documented tables. Lum96 has no documented table; inherits the default. + _ERROR_NAMES: Dict[int, str] = _GENERIC_ERROR_NAMES + def __init__(self, pid: int, device_type: ByonoyDevice) -> None: super().__init__() self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) @@ -30,6 +191,7 @@ def __init__(self, pid: int, device_type: ByonoyDevice) -> None: self._ping_interval = 1.0 self._sending_pings = False self._device_type = device_type + self._abort_requested = False async def setup(self, backend_params: Optional[BackendParams] = None) -> None: await self.io.setup() @@ -107,3 +269,212 @@ def _start_background_pings(self) -> None: def _stop_background_pings(self) -> None: self._sending_pings = False + + async def get_status(self) -> ByonoyStatus: + """Read REP_STATUS_IN (0x0300): init/slot/error/uptime/measuring/boot.""" + response = await self.send_command( + report_id=0x0300, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyStatus( + is_initialized=r.u8() != 0, + slot_state=ByonoySlotState(r.u8()), + error_code=r.u8(), + uptime_s=r.u32(), + is_measuring=r.u8() != 0, + boot_completed=r.u8() != 0, + ) + + def describe_error_code(self, code: int) -> str: + """Return a human-readable name for a firmware error_code byte. + + Looks up `code` in this backend's `_ERROR_NAMES` table. Unknown codes + fall back to `"errorCode=0xNN"` matching the C library's generic + Status::firmwareErrorId. The default table only has NO_ERROR (0); + subclasses for documented devices (Abs96, AbsOne) populate richer + tables. Lum96 has no documented table — codes other than 0 will + surface as the hex sentinel, which is the honest answer. + """ + if code in self._ERROR_NAMES: + return self._ERROR_NAMES[code] + return f"errorCode=0x{code:02x}" + + async def get_environment(self) -> ByonoyEnvironment: + """Read REP_ENVIRONMENT_IN (0x0310): temperature, humidity, acceleration.""" + response = await self.send_command( + report_id=0x0310, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + temp_c = r.i16() / 100.0 + humidity = r.i16() / 1000.0 + ax, ay, az = r.i16(), r.i16(), r.i16() + return ByonoyEnvironment( + temperature_c=temp_c, + humidity=humidity, + acceleration_g=(ax / _ACCEL_LSB_PER_G, ay / _ACCEL_LSB_PER_G, az / _ACCEL_LSB_PER_G), + ) + + async def get_api_version(self) -> ByonoyApiVersion: + """Read REP_API_VERSION_IN (0x0050): a single u32.""" + response = await self.send_command( + report_id=0x0050, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyApiVersion(version_no=r.u32()) + + async def get_supported_reports(self) -> List[int]: + """Read REP_SUPPORTED_REPORTS_IN (0x0010): list of report IDs the device supports. + + Reply is delivered in seq/seq_len chunks of up to 29 u16 ids; zero-valued + entries are padding. Returns the deduplicated, ordered union. + """ + cmd = self._assemble_command(report_id=0x0010, payload=b"\x00" * 60, routing_info=b"\x80\x40") + await self.io.write(cmd) + + seen: List[int] = [] + t0 = time.time() + while True: + if time.time() - t0 > 30: + raise TimeoutError("Timed out reading supported reports.") + chunk = await self.io.read(64, timeout=10) + if len(chunk) == 0: + continue + r = Reader(chunk) + if r.u16() != 0x0010: + continue + seq = r.u8() + seq_len = r.u8() + ids = [r.u16() for _ in range(29)] + seen.extend(i for i in ids if i != 0) + if seq == seq_len - 1: + break + # Preserve order, drop dupes + out: List[int] = [] + for i in seen: + if i not in out: + out.append(i) + return out + + async def read_data_field(self, field_index: int) -> object: + """Read a named device-data field via REP_DEVICE_DATA_READ_IN (0x0200). + + Returns the field's value typed per the response flags + (str / int / float / bool / bytes). Truncates if HAS_MORE_DATA is set + (shouldn't happen for the short identity strings; log if it does). + """ + payload = Writer().u16(field_index).u8(0).raw_bytes(b"\x00" * 57).finish() + response = await self.send_command( + report_id=0x0200, payload=payload, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + _ = r.u16() # echoed field_index + flags = r.u8() + data_type = flags & _FLAG_TYPE_MASK + if flags & _FLAG_HAS_MORE_DATA: + logger.warning( + "[Byonoy] field 0x%04X has more data than fits in one report; truncating", + field_index, + ) + raw = r.raw_bytes(52) + if data_type == _FLAG_TYPE_STRING: + return raw.split(b"\x00", 1)[0].decode("utf-8", errors="replace") + if data_type == _FLAG_TYPE_INTEGER: + return int.from_bytes(raw[:4], "little", signed=False) + if data_type == _FLAG_TYPE_FLOAT: + return Reader(raw[:4]).f32() + if data_type == _FLAG_TYPE_BOOLEAN: + return raw[0] != 0 + return raw # TypeBytes + + async def get_device_info(self) -> ByonoyDeviceInfo: + """Read identity strings (matches C lib's byonoy_get_device_information).""" + + async def s(idx: int) -> str: + v = await self.read_data_field(idx) + return v if isinstance(v, str) else str(v) + + return ByonoyDeviceInfo( + device_id=await s(_DD_DEVICE_ID), + device_name=await s(_DD_DEVICE_NAME), + manufacturer=await s(_DD_DEVICE_MANUFACTURER), + serial_no=await s(_DD_SERIAL_NO), + firmware_version=await s(_DD_FIRMWARE_VERSION), + ref_number=await s(_DD_REF_NUMBER), + ) + + async def cancel(self, report_id: int = 0x0340) -> None: + """Abort an in-progress measurement via REP_ABORT_REPORT_OUT (0x0060). + + Empirically the firmware stops emitting result chunks but does not send + any closing notification, so we also raise an `_abort_requested` flag + that subclasses' read loops poll to bail out instead of waiting 120 s + for the hard timeout. + + `report_id` is the trigger report whose execution should be aborted. + Defaults to the lum96 trigger (0x0340). + """ + self._abort_requested = True + payload = Writer().u16(report_id).raw_bytes(b"\x00" * 58).finish() + await self.send_command(report_id=0x0060, payload=payload, wait_for_response=False) + logger.info("[Byonoy] sent abort for report 0x%04X", report_id) + + async def set_led_colours(self, colours: List[Tuple[int, int, int]]) -> None: + """Set the 20-LED bar colours via REP_LED_BAR_COLOURS_OUT (0x0350). + + First switches the bar into manual SOLID mode (FLAG_LED_MANUAL) so the + firmware doesn't overwrite the colours with its own animation, then + sends the 20-pixel buffer. Pads with black if fewer than 20 are given. + """ + await self.set_led_effect(LedEffect.SOLID, manual=True) + pixels = list(colours[:20]) + [(0, 0, 0)] * max(0, 20 - len(colours)) + w = Writer() + for r_, g, b in pixels: + w.u8(r_ & 0xFF).u8(g & 0xFF).u8(b & 0xFF) + await self.send_command( + report_id=0x0350, payload=w.finish(), wait_for_response=False + ) + + async def set_led_effect( + self, + effect: LedEffect, + effect_state: int = 0, + manual: bool = False, + duration_ms: int = 0, + ) -> None: + """Set the LED bar effect via REP_LED_BAR_EFFECTS_OUT (0x0351). + + Set `manual=True` when driving dynamic effects (PROGRESS, CYLON, ...) + where you want to advance frames yourself via `effect_state`. + """ + flags = 0x02 if manual else 0 # FLAG_LED_MANUAL + payload = ( + Writer() + .u8(int(effect)) + .u8(effect_state & 0xFF) + .u8(flags) + .u32(int(duration_ms)) + .finish() + ) + await self.send_command( + report_id=0x0351, payload=payload, wait_for_response=False + ) + + async def get_versions(self) -> ByonoyVersions: + """Read REP_VERSIONS_IN (0x0080): system / STM / ESP / bootloader versions.""" + response = await self.send_command( + report_id=0x0080, payload=b"\x00" * 60, routing_info=b"\x80\x40" + ) + assert response is not None + r = Reader(response[2:]) + return ByonoyVersions( + system_version=r.u32(), + stm_version=r.u32(), + stm_dev_version=r.u32(), + esp_version=r.u32(), + esp_dev_version=r.u32(), + stm_bootloader_version=r.u32(), + ) diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 6abf9e68c9d..6f7dda0cd3e 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -1,9 +1,16 @@ +import asyncio import logging import time from dataclasses import dataclass from typing import List, Optional, Tuple -from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.byonoy.backend import ( + LUM96_PRESET_S, + ByonoyBase, + ByonoyDevice, + Lum96IntegrationMode, + encode_well_bitmask, +) from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.luminescence import ( Luminescence, @@ -38,10 +45,21 @@ class LuminescenceParams(BackendParams): """Byonoy Luminescence 96 parameters for luminescence reads. Args: - integration_time: Integration time in seconds. Default 2. + mode: One of RAPID (100 ms), SENSITIVE (2 s, default), ULTRA_SENSITIVE + (20 s), or CUSTOM. Presets match the byonoy_device_library mapping. + integration_time: Integration time in seconds. If set, forces CUSTOM + mode regardless of `mode`. Required when `mode == CUSTOM`. + selected_wells: Optional 96-bool mask in plate row-major order (A1..H12). + If None, the wells passed to `read_luminescence` decide which wells + are reported (defaulting to all 96). Note: this is an output filter, + not a measurement optimisation — the firmware scans all 96 wells in + every read and zero-fills the unselected ones in the result. Useful + for cleaner downstream processing; does not reduce read time. """ - integration_time: float = 2 + mode: Lum96IntegrationMode = Lum96IntegrationMode.SENSITIVE + integration_time: Optional[float] = None + selected_wells: Optional[List[bool]] = None async def read_luminescence( self, @@ -55,20 +73,44 @@ async def read_luminescence( Args: plate: The plate being read. wells: Wells to measure. - focal_height: Focal height in mm. + focal_height: Required by the abstract :class:`LuminescenceBackend` + contract but **ignored on the Byonoy L96** — the device has a + fixed optical configuration (the detector unit clamps onto the + base; the optical path is determined by plate + base + detector + geometry, not user-tunable). Passing any value is harmless; + passing 0 is conventional. backend_params: Backend-specific parameters. """ if not isinstance(backend_params, self.LuminescenceParams): backend_params = ByonoyLuminescence96Backend.LuminescenceParams() - integration_time = backend_params.integration_time + # Resolve mode + integration time + if backend_params.integration_time is not None: + mode = Lum96IntegrationMode.CUSTOM + integration_time = backend_params.integration_time + elif backend_params.mode == Lum96IntegrationMode.CUSTOM: + raise ValueError("CUSTOM mode requires integration_time to be set.") + else: + mode = backend_params.mode + integration_time = LUM96_PRESET_S[mode] + + # Resolve well mask + if backend_params.selected_wells is not None: + mask_bools = backend_params.selected_wells + else: + all_items = plate.get_all_items() + well_set = set(id(w) for w in wells) + mask_bools = [id(w) in well_set for w in all_items] + + well_mask = encode_well_bitmask(mask_bools, n=96) logger.info( - "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', integration_time=%.1fs, wells=%d/%d", + "[Byonoy L96 pid=0x%04X] reading luminescence: plate='%s', mode=%s, " + "integration_time=%.3fs, wells=%d/96", self.io.pid, plate.name, + mode.name, integration_time, - len(wells), - plate.num_items, + sum(mask_bools), ) await self.send_command( @@ -85,8 +127,14 @@ async def read_luminescence( ) payload3 = ( - Writer().i32(int(integration_time * 1000 * 1000)).raw_bytes(b"\xff" * 12).u8(0).u8(0).finish() + Writer() + .i32(int(integration_time * 1_000_000)) + .raw_bytes(well_mask) + .u8(0) # is_reference_measurement + .u8(0) # flags + .finish() ) + self._abort_requested = False await self.send_command( report_id=0x0340, payload=payload3, @@ -97,11 +145,15 @@ async def read_luminescence( all_rows: List[Optional[float]] = [] while True: + if self._abort_requested: + self._abort_requested = False + logger.info("[Byonoy L96 pid=0x%04X] read aborted by cancel()", self.io.pid) + raise asyncio.CancelledError("Luminescence read aborted via cancel().") if time.time() - t0 > 120: logger.error("[Byonoy L96 pid=0x%04X] luminescence read timed out after 120s", self.io.pid) raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - chunk = await self.io.read(64, timeout=30) + chunk = await self.io.read(64, timeout=2) if len(chunk) == 0: continue