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 next setup() 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

RAPID

100 ms

Saturation checks, quick “is it bright?”

SENSITIVE

2 s (default)

Most assays — luciferase, BRET, NanoBiT

ULTRA_SENSITIVE

20 s

Very faint signals; low-copy reporters

CUSTOM

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 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.

# 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: 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.

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.

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

setup() raises “device already open”

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

slot_state=UNKNOWN

No plate loaded

Expected — firmware cannot detect “nothing” definitively

Read takes 28 s for one well in SENSITIVE

Firmware always integrates 96 wells

Use RAPID for fast, accept the 28 s for SENSITIVE

cancel() returns immediately, read keeps going

No measurement in flight

cancel() auto-detects the in-flight trigger; no-op if there isn’t one

Negative readings on empty wells

Firmware baseline subtraction

Expected — they should sit around zero

focal_height parameter has no effect

L96 has fixed optics

Expected — parameter is ABC contract, ignored on this device

13. Reference#

  • 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.