Byonoy Luminescence 96 — lab guide#
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).
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).
1. Connect#
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.
One process at a time. macOS / Windows / Linux all give exclusive HID access. If a previous Python session crashed without
stop(), the nextsetup()will fail with “device already open”. Replug the USB cable to force-release.
from pylabrobot.byonoy import byonoy_l96 # or byonoy_l96a for the automate variant
base, reader = byonoy_l96(name="l96")
await reader.setup()
Resource layout#
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:
ByonoyLuminescenceBaseUnit (base)
+-- plate_holder (assign plates here)
+-- reader_unit_holder (reader unit sits here during measurement)
2. Load a plate#
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.
After running this cell, physically place the plate in the reader and place the detector back on top.
from pylabrobot.resources import Cor_96_wellplate_360ul_Fb
base.reader_unit_holder.unassign_child_resource(reader) # take detector off
plate = Cor_96_wellplate_360ul_Fb(name="plate")
base.plate_holder.assign_child_resource(plate)
3. Read — the basics#
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.
Result shape#
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.
Background#
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.
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.
results = await reader.luminescence.read(plate=plate, focal_height=0)
data = results[0].data # 8 × 12 list[list[float]]
timestamp = results[0].timestamp # epoch seconds
print(f"timestamp={timestamp}")
for row in data:
print(" " + " ".join(f"{v:8.2f}" for v in row))
4. Picking an integration mode#
Four modes, mapping to the byonoy_device_library presets:
Mode |
Integration time |
Use for |
|---|---|---|
|
100 ms |
Saturation checks, quick “is it bright?” |
|
2 s (default) |
Most assays — luciferase, BRET, NanoBiT |
|
20 s |
Very faint signals; low-copy reporters |
|
user-supplied |
Your own duration |
from pylabrobot.byonoy import ByonoyLuminescence96Backend
# Preset
results = await reader.luminescence.read(
plate=plate,
focal_height=0,
backend_params=ByonoyLuminescence96Backend.LuminescenceParams(
mode="ultra_sensitive",
),
)
# Custom (any duration in seconds) — auto-switches to CUSTOM mode
results = await reader.luminescence.read(
plate=plate,
focal_height=0,
backend_params=ByonoyLuminescence96Backend.LuminescenceParams(
integration_time=5.0,
),
)
5. Reading specific wells#
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).
No speed-up. The firmware always integrates the whole 96-sensor array. Reading one column with
SENSITIVEtakes the same wall-clock as reading the full plate (~28 s in our hardware test). Thewellsfilter keeps your downstream tidy — it doesn’t save time. If you want fast, useRAPIDmode.
# Only column 1 (A1, B1, ..., H1)
col1_wells = [plate.get_well(f"{r}1") for r in "ABCDEFGH"]
results = await reader.luminescence.read(
plate=plate,
focal_height=0,
wells=col1_wells,
)
# results[0].data[0][0] is A1 (float); results[0].data[0][1] is A2 (None)
6. Timed read (delay before reading)#
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.
import asyncio
# ... pipette substrate into the plate ...
await asyncio.sleep(60) # 60 s incubation
results = await reader.luminescence.read(plate=plate, focal_height=0)
7. Kinetic read (time series)#
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.
import asyncio, time
import numpy as np
frames = []
duration_s = 600 # 10 minutes total
interval_s = 30 # one read every 30 s
t_start = time.time()
while time.time() - t_start < duration_s:
t_read = time.time()
results = await reader.luminescence.read(plate=plate, focal_height=0)
frames.append({
"t": t_read - t_start,
"data": results[0].data,
})
elapsed = time.time() - t_read
if elapsed < interval_s:
await asyncio.sleep(interval_s - elapsed)
matrix_stack = np.array([f["data"] for f in frames]) # (n_frames, 8, 12)
times = np.array([f["t"] for f in frames])
print(f"collected {len(frames)} frames over {duration_s} s")
# Trace for well C6:
trace = matrix_stack[:, 2, 5]
8. Stopping a long read#
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.
task = asyncio.create_task(
reader.luminescence.read(plate=plate, focal_height=0,
backend_params=ByonoyLuminescence96Backend.LuminescenceParams(
mode="ultra_sensitive",
),
)
)
await asyncio.sleep(1.0)
await reader.driver.cancel()
try:
await task
except asyncio.CancelledError:
print("aborted cleanly")
9. Device health & identity#
Useful at the start of a session, in error messages, or for run logging.
slot_state:OCCUPIEDwhen a plate is loaded,UNKNOWNwhen nothing is in the reader (the firmware can’t tell empty from missing). Don’t treatUNKNOWNas an error.
error_code:0isNO_ERROR. The Lum96 firmware doesn’t publish a documented table for non-zero values, so non-zero codes surface aserrorCode=0xNN(matching what Byonoy’s own C library returns). For Abs96 / AbsOne backends, names likeERROR_CALIB/AMBIENT_LIGHTare decoded automatically.
status = await reader.driver.request_status()
env = await reader.driver.request_environment()
info = await reader.driver.request_device_info()
versions = await reader.driver.request_versions()
api = await reader.driver.request_api_version()
supported = await reader.driver.request_supported_reports()
print(f"{info.device_name} sn={info.serial_no} fw={info.firmware_version}")
print(f" uptime {status.uptime_s} s, T={env.temperature_c:.1f}°C, RH={env.humidity*100:.0f}%")
print(f" slot: {status.slot_state.name}, error: {reader.driver.describe_error_code(status.error_code)}")
print(f" api v{api}, fw production={versions.is_production}")
print(f" supported reports ({len(supported)}): " + ", ".join(f"0x{i:04X}" for i in supported))
10. Visual feedback (LED bar)#
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.
See the dedicated LED bar notebook for the full surface and recipes (KITT scanner, gradients, etc.). Quick taste below.
await reader.driver.set_led_color((0, 255, 0)) # solid green: ready
await reader.driver.set_led_color((0, 255, 0), "breathing", duration_ms=10000) # busy
await reader.driver.set_led_colors([(255, 0, 0)] * 10 + [(0, 0, 255)] * 10) # per-pixel split
11. End-point luciferase recipe#
End-to-end workflow for a typical end-point luciferase assay.
import asyncio, time
import numpy as np
from pylabrobot.byonoy import byonoy_l96, ByonoyLuminescence96Backend
from pylabrobot.resources import Cor_96_wellplate_360ul_Fb
# Connect
base, reader = byonoy_l96(name="assay")
await reader.setup()
await reader.driver.set_led_color((255, 150, 0)) # amber: prep
# Sanity check
status = await reader.driver.request_status()
info = await reader.driver.request_device_info()
print(f"{info.device_name} sn={info.serial_no} — {status.slot_state.name}")
assert status.error_code == 0
# Load plate
base.reader_unit_holder.unassign_child_resource(reader)
plate = Cor_96_wellplate_360ul_Fb(name="assay_plate")
base.plate_holder.assign_child_resource(plate)
# (operator places plate, places detector back on top)
# Read — green while measuring
await reader.driver.set_led_color((0, 255, 0))
results = await reader.luminescence.read(
plate=plate,
focal_height=0,
backend_params=ByonoyLuminescence96Backend.LuminescenceParams(
mode="sensitive",
),
)
data = np.array(results[0].data) # 8 × 12
# Save + tidy up
np.save(f"luminescence_{int(time.time())}.npy", data)
await reader.driver.set_led_color((0, 0, 0)) # off
await reader.stop()
12. Troubleshooting#
Symptom |
Likely cause |
Fix |
|---|---|---|
|
Previous Python session left HID handle locked |
Replug USB cable, or kill stale Python processes |
All wells read very high (10⁴–10⁶) with no sample |
Light leak through housing bottom |
Use a dark mat or close the room |
Strong gradient A→H with no sample |
Directional light leak (one side leakier) |
Same — dark mat |
|
No plate loaded |
Expected — firmware cannot detect “nothing” definitively |
Read takes 28 s for one well in SENSITIVE |
Firmware always integrates 96 wells |
Use |
|
No measurement in flight |
|
Negative readings on empty wells |
Firmware baseline subtraction |
Expected — they should sit around zero |
|
L96 has fixed optics |
Expected — parameter is ABC contract, ignored on this device |
13. Reference#
Hardware protocol: HID 64-byte frames, vid
0x16D0, pid0x119B. Report IDs decoded from Byonoy’sbyonoyusbhid.h.routing_info=\x80\x40requests a reply;\x00\x00is fire-and-forget.