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

Byonoy
Luminescence 96
Fullv1
plate reader
luminescence
APIpylabrobot.byonoy.ByonoyLuminescence96
Byonoy
Luminescence 96 Automate
Fullv1
plate reader
luminescence
APIpylabrobot.byonoy.ByonoyLuminescence96

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.