62 KiB
Byonoy Device Library — Python SDK
Read the SDK guide first: it explains the two variants, how the library behaves, and the error codes. This document is the Python specifics.
Everything except section 5 is the public variant. Section 5 covers what the internal variant adds.
1. Install
The public package is on the public registry and needs no credentials. It is published as wheels only, for these platforms (release 2026.9.1):
| OS | Architecture | Python |
|---|---|---|
| Linux | x86_64 | 3.10 – 3.14 |
| Linux | aarch64 | 3.12, 3.14 |
| Windows | x86-64 | 3.10 – 3.14 |
| macOS | arm64, x86_64 | 3.13, 3.14 |
Always install into a virtual environment. Many managed interpreters (Homebrew,
Debian) refuse to install into themselves (error: externally-managed-environment,
PEP 668); others quietly fall back to a --user install, which is just as easy
to lose track of. Use python -m pip from the environment, not a bare pip,
which is frequently absent.
Linux and macOS
On Linux, install two system packages first — venv support, which
Debian and Ubuntu ship separately, and the hidapi library the wheel links
against:
sudo apt update && sudo apt install -y python3-venv libhidapi-libusb0 # Debian, Ubuntu
sudo dnf install hidapi # Fedora
Then, on Linux and macOS alike:
python3 -m venv .venv
./.venv/bin/python -m pip install \
--index-url https://git.byonoy.com/api/packages/public/pypi/simple/ \
byonoy_devices
On macOS, python3 must be 3.13 or 3.14; if it is older, name a supported
interpreter explicitly (python3.13 -m venv .venv). Over a non-interactive ssh
session Homebrew's /opt/homebrew/bin is usually not on PATH; add it, or
call the interpreter by its full path. And the device only opens while a user
is logged in to a desktop session on the Mac — see
macOS and Windows in the SDK guide.
On Linux, the device also needs permissions before you can open it — see
Linux permissions in the SDK guide, which uses
the 70-byonoy.rules file shipped next to it.
If Linux setup went wrong, you will see one of these:
| Message | Cause and fix |
|---|---|
The virtual environment was not created successfully because ensurepip is not available |
python3-venv is missing. Install it, delete the half-created .venv, and create it again — the broken one has no pip in it |
ImportError: libhidapi-libusb.so.0: cannot open shared object file |
hidapi is missing. Install libhidapi-libusb0 (Debian, Ubuntu) or hidapi (Fedora) |
Unable to locate package or has no installation candidate |
the package lists are empty, as on fresh cloud and container images. Run sudo apt update first |
Windows (PowerShell)
On Windows, python3 is usually only the Microsoft Store placeholder (it prints
Python was not found and exits with 9009). Use the py launcher or python:
py -0p # lists installed interpreters; pick a supported one
py -3.11 -m venv .venv # or: python -m venv .venv
.\.venv\Scripts\python.exe --version
.\.venv\Scripts\python.exe -m pip install --disable-pip-version-check --index-url https://git.byonoy.com/api/packages/public/pypi/simple/ byonoy_devices
(--disable-pip-version-check only silences pip's update check, which
PowerShell would otherwise show as a red error.) Check the version the environment actually got: some py launchers fall back
to the default interpreter without a warning when the one you name is not
installed.
No driver or permission step is needed. The rest of this document writes commands for bash; in PowerShell:
| bash | PowerShell |
|---|---|
./.venv/bin/python |
.\.venv\Scripts\python.exe |
NAME=value command |
$env:NAME='value'; command (stays set for the session; Remove-Item Env:NAME clears it) |
python -c '…' with quotes inside |
a script file — quoting rarely survives PowerShell, least of all over ssh |
a trailing \ to continue a line |
one line, or a trailing backtick ` |
> out.csv |
--output out.csv where the program offers it — PowerShell 5.1 re-encodes redirected output as UTF-16 |
timeout --foreground 300 command |
with_timeout.py — see Long calls |
The longer programs in this document are plain Python files, so they run the
same way on every OS. Windows PowerShell 5.1 shows anything a program writes to
stderr — pip's warnings, status messages — as a red error record even when the
program succeeded; check $LASTEXITCODE instead.
On Windows a virtual environment's python.exe is a small launcher that starts
the base interpreter as a child, so Get-Process python shows two entries
for every running program. That is not a second program holding the device;
ending the launcher ends the child too.
If there is no wheel for you
There is no source distribution — wheels are built per OS, architecture and Python version, so not every combination exists. An unsupported interpreter gives:
ERROR: Could not find a version that satisfies the requirement byonoy_devices (from versions: none)
ERROR: No matching distribution found for byonoy_devices
That means the interpreter/platform pair has no wheel, not that anything is wrong with your setup. Check what exists before debugging (bash):
PYTAG=cp$(python3 -c 'import sys; print(f"{sys.version_info.major}{sys.version_info.minor}")')
case "$(uname -s)" in Darwin) PLAT=macosx ;; Linux) PLAT=linux ;; *) PLAT=win ;; esac
echo "looking for $PYTAG / $PLAT"
curl -fsS https://git.byonoy.com/api/packages/public/pypi/simple/byonoy-devices/ \
| grep -oE '[a-z_0-9.+-]+\.whl' | sort -u | grep "$PYTAG" | grep "$PLAT"
In PowerShell:
$tag = "cp" + (python -c "import sys; print(f'{sys.version_info.major}{sys.version_info.minor}')")
(Invoke-WebRequest -UseBasicParsing https://git.byonoy.com/api/packages/public/pypi/simple/byonoy-devices/).Content |
Select-String -AllMatches '[\w.+-]+\.whl' | ForEach-Object { $_.Matches.Value } |
Sort-Object -Unique | Where-Object { $_ -match $tag -and $_ -match 'win' -and $_ -match '2026\.9\.1' }
Both filters matter — the ABI tag alone lists wheels for every platform. (The PowerShell version also filters on the release; without that it lists every older wheel too.) Switch to a supported interpreter rather than fighting the resolver.
2. Smallest complete program
import byonoy_devices as byonoy
devices = byonoy.available_devices()
if not devices:
raise SystemExit("no Byonoy device attached")
rc, handle = byonoy.open_device(devices[0])
if rc != byonoy.ErrorCode.NO_ERROR:
raise SystemExit(f"open failed: {rc}")
try:
rc, info = byonoy.get_device_information(handle)
if rc != byonoy.ErrorCode.NO_ERROR:
raise SystemExit(f"device info failed: {rc}")
print(info.type.name, info.sn, info.ref_no, info.version)
finally:
byonoy.free_device(handle)
Always close in a finally. An abandoned handle keeps a worker thread and the
USB device claimed for the life of the process.
The info object carries exactly four fields:
| Field | Meaning |
|---|---|
type |
device model, a DeviceTypes enum member |
sn |
serial number |
ref_no |
Byonoy reference/article number |
version |
the device's firmware version, as a string whose format differs between devices (8, 41, Absorbance One V1.3.3) |
3. Calling convention
Functions come in three shapes. Getting this wrong is the most common mistake:
| Shape | Example | Returns |
|---|---|---|
| produces a value | get_device_information, lum96_measure |
tuple (ErrorCode, value) |
| performs an action | abs96_initialize_single_measurement |
ErrorCode alone |
| asks a question | lum96_measurement_supported, device_open |
plain bool |
So rc, info = byonoy.get_device_information(h) — never info = ....
A few plain accessors return a bare value and no code at all:
available_devices, available_devices_count, library_version,
enable_logging, and the free_* functions.
ErrorCode is not an int
ErrorCode is a pybind11 enum, not an IntEnum. Compare it directly
(rc == byonoy.ErrorCode.NO_ERROR); to log or serialise a number use
rc.value, because int(rc) raises TypeError.
Never write
if rc == 0:orif rc != 0:. Neither raises.rc == 0is alwaysFalseandrc != 0is alwaysTrue, so a success test never fires and a failure test fires on every call — including the successful ones.
4. Taking a measurement
Ask first whether the attached device supports the modality:
if not byonoy.lum96_measurement_supported(handle):
raise SystemExit("this device does not do 96-well luminescence")
cfg = byonoy.Lum96MeasurementConfig()
cfg.mode = byonoy.Lum96IntegrationMode.RAPID # ~10 s; SENSITIVE is ~60 s
cfg.selected_wells = [True] * 96 # REQUIRED — see below
rc, values = byonoy.lum96_measure(handle, cfg)
if rc != byonoy.ErrorCode.NO_ERROR:
raise SystemExit(f"measurement failed: {rc}")
print(len(values), "wells")
You must set
selected_wells. A fresh config has all 96 entriesFalse. On a healthy device, measuring with it succeeds —NO_ERROR, in a fraction of a second, with a full-length result of 96 zeros. Nothing reports a problem, and a plate of zeros is indistinguishable from a dark reading. The list must be exactly 96 entries; any other length raisesTypeError. Setmodeexplicitly too rather than relying on the default.
To select part of a plate, index the list in the well order — row-major, so entry 0 is A1 and entry 12 is B1:
cfg.selected_wells = [i // 12 in (0, 1) for i in range(96)] # rows A and B only
Lum96IntegrationMode has four members, in increasing integration time:
RAPID, SENSITIVE, ULTRA_SENSITIVE, and CUSTOM. CUSTOM takes its
duration from the config's custom_integration_time_ms field; the other three
ignore it.
Mind the duration. The modes set the integration time per detector channel:
RAPID100 ms,SENSITIVE2 s,ULTRA_SENSITIVE20 s. A full plate takes roughly 30 × that — about 10 s, 60 s and 10 minutes. Never end anULTRA_SENSITIVEread early: on a test unit, stopping one after 9 minutes left the device unable to measure until it was power-cycled.CUSTOMmust be at least 50 ms (INVALID_ARGUMENTotherwise) and has no upper limit; its duration follows the same formula (see the SDK guide's timing notes).
Absorbance
Absorbance needs an initialise with the slot empty, then a measure with the plate (or cuvette) in. For an Absorbance 96 Automate:
if not byonoy.abs96_measurement_supported(handle):
raise SystemExit("this device does not do 96-well absorbance")
# REQUIRED before a single-wavelength initialise, which otherwise fails with
# INVALID_ARGUMENT. It also tells you which wavelengths are valid.
rc, wavelengths = byonoy.abs96_get_available_wavelengths(handle)
if rc != byonoy.ErrorCode.NO_ERROR:
raise SystemExit(f"wavelength query failed: {rc}")
cfg = byonoy.Abs96SingleMeasurementConfig()
cfg.sample_wavelength = wavelengths[0]
cfg.reference_wavelength = 0 # 0 = no reference wavelength
cfg.rapid_mode = False
rc = byonoy.abs96_initialize_single_measurement(handle, cfg) # slot empty
if rc != byonoy.ErrorCode.NO_ERROR:
raise SystemExit(f"initialise failed: {rc}")
# ... wait until get_device_slot_status reports OCCUPIED ...
rc, values = byonoy.abs96_single_measure(handle, cfg) # plate in
Rules that are easy to miss:
- Query the wavelengths first, on every handle. The library checks an
initialise's wavelengths against what
abs96_get_available_wavelengthsreturned on this handle, so a single-wavelength initialise on a fresh handle returnsINVALID_ARGUMENTuntil you have queried. (The multiple-wavelength initialise currently skips that check; query anyway.) - Initialisation is per wavelength, and belongs to the handle. Every
initialise records the wavelengths it calibrated — a single one its sample
and reference wavelength — and a measure succeeds only if every wavelength it
asks for is recorded; otherwise it returns
NOT_INITIALIZED. After closing and reopening, or in a new process, nothing is recorded. - Single and multiple are the same operations. A multiple-wavelength initialise or measure is a sequence of single-wavelength ones, without a reference wavelength. You can therefore mix them — measure one wavelength after a multiple initialise, or several after single ones — as long as each wavelength was initialised. A reference wavelength is only applied by the single calls.
- Wavelengths differ between units. Take them from
abs96_get_available_wavelengths; do not hard-code one. - The library does not check for a plate. A measure on an empty slot
returns
NO_ERRORand meaningless near-zero values. Pollget_device_slot_status(EMPTYbefore initialising,OCCUPIEDbefore measuring), as the complete example does.
The Absorbance One works the same way with absone_initialize_measurement
and absone_measure, but it cannot sense its slot, and on a faulty device
absone_measure can block indefinitely instead of failing. Check that the
initialise took effect before measuring:
rc, initialized = byonoy.absone_is_initialized(handle) # gated on absone_is_initialized_supported
Which modality does this device support?
Capability names do not follow mechanically from the measure-function names —
notably the two abs96 measure functions share one predicate. Use this
table rather than guessing:
| Predicate | Measure function(s) |
|---|---|
lum96_measurement_supported |
lum96_measure |
flu96_measurement_supported |
flu96_measure |
abs96_measurement_supported |
abs96_single_measure, abs96_multiple_measure |
absone_measurement_supported |
absone_measure |
Decide the modality from these predicates only; helper predicates such as
abs96_available_wavelengths_supported can answer True on devices that
cannot measure that modality. To see every predicate your module exposes:
print(sorted(n for n in dir(byonoy) if n.endswith("_supported")))
Is my reading plausible?
Useful when verifying a setup, since you otherwise cannot tell a working install
from a broken one. Observed on a Luminescence 96 with nothing inserted: values
(in RLU) are whole numbers (as floats, e.g. -35.0) scattered around zero, and negative values are normal —
the signal is background-corrected, so noise falls on both sides of zero. The
spread depends on the mode:
| Mode | Standard deviation | All values within |
|---|---|---|
RAPID |
~130 | about ±400 |
SENSITIVE |
~40 | about ±200 |
Do not treat a wide RAPID scatter as a fault. A single well reading exactly
0 is possible.
Long calls
A measurement blocks for seconds to minutes and Ctrl-C will not interrupt it.
Python also block-buffers stdout when it is redirected, so a script printing
progress shows nothing until it finishes — use python -u (or flush=True)
whenever you pipe or redirect.
A call can also block indefinitely (see the SDK guide's
timing notes). To bound it, run the
program as a child process that is ended when a limit passes. On Linux,
timeout --foreground 300 … does that — plain timeout without
--foreground detaches the program from the terminal, so its "press Enter"
prompts never receive your input. macOS and Windows have no timeout. This
works everywhere — save it as with_timeout.py:
"""Run a command and end it if it takes longer than a limit.
python with_timeout.py 300 python -u measure_to_csv.py --output plate.csv
"""
import subprocess
import sys
limit = float(sys.argv[1])
try:
sys.exit(subprocess.run(sys.argv[2:], timeout=limit).returncode)
except subprocess.TimeoutExpired:
print(f"with_timeout: ended after {limit:.0f} s; the device may need a replug",
file=sys.stderr)
sys.exit(124)
Use the environment's interpreter for both (./.venv/bin/python with_timeout.py 300 ./.venv/bin/python -u …). Before the next attempt, make sure nothing is
left holding the device: pgrep -fl python on Linux and macOS,
Get-Process python on Windows. Ending a process in the middle of a measurement
gives the library no chance to clean up: afterwards the device may refuse to
measure or drop off USB until it is replugged. Treat the timeout as a way to
keep your program alive, not as a routine control.
Useful entry points
byonoy.library_version() # .major .minor .patch
byonoy.available_devices_count()
byonoy.enable_logging(True) # verbose protocol logging to STDOUT
byonoy.get_device_status(handle) # (ErrorCode, DeviceState): OK, ERROR, BROKEN_FW, UNKNOWN
byonoy.get_device_error(handle) # (ErrorCode, int): last device error, 0 = none; call get_device_status first
byonoy.device_open(handle) # bool, still connected?
byonoy.get_device_slot_status(handle) # (ErrorCode, DeviceSlotState): EMPTY, OCCUPIED, UNDETERMINED, UNKNOWN
byonoy.get_device_temperature(handle) # (ErrorCode, float) °C, gated on device_temperature_supported
byonoy.get_device_humidity(handle) # (ErrorCode, float) relative humidity as a fraction (0.43 = 43 %)
byonoy.get_device_uptime(handle) # (ErrorCode, int) seconds, gated on device_uptime_supported
See the SDK guide on device status and device error.
UNDETERMINED means the device itself cannot tell whether a plate is present;
a Luminescence 96 reports slot support but always answers UNDETERMINED.
UNKNOWN means the slot query failed. Humidity is reported only by the
luminescence and fluorescence devices; gate it on device_humidity_supported.
enable_logging(True) writes to stdout, unconditionally. If your program
prints results to stdout, the protocol log interleaves with them.
library_version() reports the version compiled into the C library, which is
not the version of the wheel you installed and may be older. Use
importlib.metadata.version("byonoy_devices") to identify the package. Expect
the same release to be spelled several ways:
| Final release | Pre-release | |
|---|---|---|
| Tag | v2026.09.1 |
v2026.09.1-beta1 |
| pip install line | 2026.9.1 |
2026.9.1b1 |
importlib.metadata |
v2026.09.1 |
v2026.09.1beta1 |
pip list shows either spelling, depending on the pip version.
5. The internal variant
Available on request; see the SDK guide for when you should want it. Everything above still applies — the module is a superset, and the only change to existing code is the import.
Install
Same command, different registry and package name, and it needs a token:
python3 -m venv .venv
./.venv/bin/python -m pip install \
--index-url "https://${BYONOY_USER}:${BYONOY_TOKEN}@git.byonoy.com/api/packages/sw/pypi/simple/" \
byonoy_devices_internal
import byonoy_devices_internal as byonoy # the only source change
On the internal registry, a platform can at times be served only by pre-release versions — before 2026.9.1, macOS wheels existed only as betas. Tools disagree about that:
| Tool | Behaviour |
|---|---|
pip |
falls back to a pre-release when no final version has a usable wheel — the command above just works |
uv pip |
refuses, reporting only the platforms final releases cover. Add --prerelease=allow |
uv venv --python 3.13 .venv # name an interpreter that has a wheel
uv pip install --python .venv/bin/python --prerelease=allow \
--index-url "https://${BYONOY_USER}:${BYONOY_TOKEN}@git.byonoy.com/api/packages/sw/pypi/simple/" \
byonoy_devices_internal
Two uv traps. A uv venv environment has no pip inside it, so
python -m pip fails there with No module named pip; stay on uv pip once
you start with uv. And uv venv --python 3.13 may download an interpreter
whose architecture is not your machine's — on an Apple Silicon Mac it can fetch
an x86_64 CPython and then install x86_64 wheels, which run under Rosetta. The
wheel is chosen by the interpreter's architecture, not the machine's.
What it adds
79 functions beyond the public module, in these areas:
| Area | Entry points |
|---|---|
| Asynchronous measurements | lum96_measure_async, lum96_measure_completed, and the same pair for flu96, absone; abs96_single_measure_async / abs96_multiple_measure_async share abs96_measure_completed |
| Data fields | data_fields_supported, enumerate_data_fields, get_known_data_field_infos, read_*_field_by_name / _by_id, write_*_field_* |
| Files | files_supported, enumerate_files, get_known_file_infos, read_file |
| RPC | rpc_supported, enumerate_rpcs, get_known_rpcs, execute_rpc_name, execute_rpc_id |
| Diagnostics | get_status_report, get_esp_status_report, get_environment_report, get_versions, get_progress |
| LEDs | led_effect_supported and the LED effect calls |
| Bootloader / flashing | open_device_in_bootloader, flash_stm, flash_esp, lock_bootloader |
| Device control | reboot |
Each area has its own *_supported predicate — gate on it as you would for a
measurement modality.
Asynchronous measurements
The reason most people ask for the internal variant: a blocking measurement holds your thread for up to a minute.
rc = byonoy.lum96_measure_async(handle, cfg)
while not byonoy.lum96_measure_completed(handle):
time.sleep(0.5)
# do other work here
Rebooting the device
The recovery described in the SDK guide, without unplugging anything:
byonoy.reboot(handle)
byonoy.free_device(handle)
time.sleep(5)
rc, handle = byonoy.open_device(byonoy.available_devices()[0])
get_versions(handle) (gated on versions_supported) gives the component
firmware versions behind the single info.version string.
6. Complete example: measure and export CSV
A complete program that takes one realistic measurement on whatever device is attached and writes it as CSV. It is deliberately verbose: every return code is checked, the device is always released, and nothing is measured until the device, the plate and the configuration have all been checked. Use it as it stands, or as the template for your own program.
What it shows, per device:
| Device | Modality | Flow |
|---|---|---|
| Absorbance 96 Automate | abs96 |
pick wavelength(s) from those available → initialise with the slot empty → insert plate → measure; one wavelength uses the single-measurement call (optionally with a reference wavelength), several use the multiple-measurement call |
| Absorbance One | absone |
the device's one wavelength → initialise with the slot empty → insert cuvette → check it initialised → measure; a single value |
| Luminescence 96 | lum96 |
set mode (RAPID or SENSITIVE) and all wells explicitly → confirm plate → measure |
| Fluorescence 96 | flu96 |
pick a filter set from those available → as luminescence |
A combined device supports more than one modality; the program takes the first
it finds and --modality picks the other. Whether a device is combined shows
only once it is open, from its predicates — not from the discovery type, which
reads AbsorbanceOneOr96 for every Absorbance One.
Before anything else it checks that the device status is OK and the device
error is 0, and refuses to measure otherwise.
Plate handling depends on what the device can sense:
| Device | Slot state | What the program does |
|---|---|---|
| Absorbance 96 Automate | EMPTY / OCCUPIED |
polls the slot, with a timeout (--plate-timeout, default 120 s); --assume-ready has no effect |
| Absorbance One | not supported | asks the operator twice — slot empty before initialising, cuvette in before measuring |
| Luminescence 96 | always UNDETERMINED (cannot tell) |
asks the operator once |
| Fluorescence 96 | varies | asks the operator once |
Without a terminal the program cannot ask, and stops with an error unless
given --assume-ready. On an Absorbance One that flag vouches for both
states — an empty slot at initialise and a cuvette at measure — so use it only
in a setup that guarantees both. That makes the program safe to run from a
script or an agent: it measures a plate someone has vouched for, or it stops
with an error.
Save it as measure_to_csv.py next to your .venv and run it with the
environment's interpreter:
./.venv/bin/python -u measure_to_csv.py --output plate.csv # whatever is attached
./.venv/bin/python -u measure_to_csv.py --modality abs96 --wavelength 492 --output plate.csv
./.venv/bin/python -u measure_to_csv.py --mode sensitive --assume-ready --output lum.csv
./.venv/bin/python -u measure_to_csv.py --modality flu96 --filter-set 2 --output flu.csv
.\.venv\Scripts\python.exe -u measure_to_csv.py --output plate.csv
Wavelengths differ between units: the program logs the available ones as it
starts, and without --wavelength it measures all of them.
Prefer --output to redirecting stdout. The program writes the file only after
the measurement has succeeded — a failed run leaves an existing file of that
name untouched, so check the exit code rather than the file's presence. By
contrast, > plate.csv creates an empty file even when the run fails — and Windows PowerShell 5.1 re-encodes redirected output as
UTF-16, which most CSV readers reject.
#!/usr/bin/env python3
"""Take one measurement on an attached Byonoy device and write it as CSV.
The modality is chosen from what the device reports it supports, or forced
with --modality. Results go to stdout (or --output); every status message goes
to stderr, so the CSV stays clean when you redirect or pipe it.
python -u measure_to_csv.py --output plate.csv
python -u measure_to_csv.py --modality abs96 --wavelength 492 --output plate.csv
"""
import argparse
import csv
import datetime
import sys
import time
import byonoy_devices as byonoy # internal variant: import byonoy_devices_internal as byonoy
CSV_COLUMNS = [
"timestamp_utc",
"serial_number",
"ref_no",
"firmware_version",
"device_type",
"modality",
"mode",
"wavelength_nm",
"reference_wavelength_nm",
"filter_set_index",
"excitation_nm",
"emission_nm",
"well_index",
"well",
"value",
"unit",
]
# ULTRA_SENSITIVE (about 10 minutes per full plate) and CUSTOM are left out on
# purpose: stopping a read early has left test devices unable to measure.
INTEGRATION_MODES = ["rapid", "sensitive"]
class MeasurementError(Exception):
pass
def log(message):
print(message, file=sys.stderr, flush=True)
def well_name(index, columns=12):
"""Results are row-major: index 0 is A1, 11 is A12, 12 is B1, 95 is H12."""
return f"{chr(ord('A') + index // columns)}{index % columns + 1}"
def describe(rc):
return f"{rc.name} (0x{rc.value:04x})"
def check(rc, what):
"""Raise unless rc is NO_ERROR. Never compare rc against 0."""
if rc != byonoy.ErrorCode.NO_ERROR:
hint = ""
if rc == byonoy.ErrorCode.DEVICE_COMMUNICATION_FAILURE:
hint = " (is another program still holding the device open? on Linux: permissions?)"
elif rc == byonoy.ErrorCode.DEVICE_OPERATION_FAILED:
hint = " (if every measurement fails like this: 'When the device stops measuring')"
raise MeasurementError(f"{what} failed: {describe(rc)}{hint}")
# --- device discovery -------------------------------------------------------
def open_device(serial_number):
devices = byonoy.available_devices()
if not devices:
raise MeasurementError(
"no Byonoy device found (on Linux, check the udev rules in the SDK guide)"
)
for d in devices:
log(f"found {d.type.name} sn={d.sn}")
if not d.sn:
log(" (no serial number: on Linux, missing permissions — or, after an aborted "
"run, a device that needs replugging)")
if serial_number is not None:
devices = [d for d in devices if d.sn == serial_number]
if not devices:
raise MeasurementError(f"no device with serial number {serial_number}")
elif len(devices) > 1:
raise MeasurementError("more than one device attached; pick one with --serial")
rc, handle = byonoy.open_device(devices[0])
check(rc, "open_device")
return handle
def preflight(handle):
rc, info = byonoy.get_device_information(handle)
check(rc, "get_device_information")
log(f"opened {info.type.name} sn={info.sn} ref={info.ref_no} fw={info.version}")
rc, state = byonoy.get_device_status(handle)
check(rc, "get_device_status")
rc, device_error = byonoy.get_device_error(handle)
check(rc, "get_device_error")
if state != byonoy.DeviceState.OK or device_error:
raise MeasurementError(
f"device status is {state.name}, device error {device_error} ({device_error:#x}); not measuring "
"(report both, with the serial number and firmware, to Byonoy support)"
)
return info
def detect_modality(handle):
# Order matters only for combined devices (AbsorbanceOneOr96); use
# --modality to pick the other one.
candidates = [
("abs96", byonoy.abs96_measurement_supported),
("absone", byonoy.absone_measurement_supported),
("lum96", byonoy.lum96_measurement_supported),
("flu96", byonoy.flu96_measurement_supported),
]
supported = [name for name, predicate in candidates if predicate(handle)]
if not supported:
raise MeasurementError("device supports none of the known measurement modalities")
log(f"supported modalities: {', '.join(supported)}")
return supported
# --- plate handling ---------------------------------------------------------
def slot_state(handle):
"""Current slot state, or None if the device cannot report one."""
if not byonoy.device_slot_status_supported(handle):
return None
rc, state = byonoy.get_device_slot_status(handle)
check(rc, "get_device_slot_status")
return state
def wait_for_slot(handle, wanted, instruction, args):
"""Wait until the slot reports `wanted`, or until the operator confirms."""
state = slot_state(handle)
if state is None:
confirm(instruction, args)
return
if state == wanted:
return
log(f"{instruction} (waiting up to {args.plate_timeout:.0f} s for the slot to read {wanted.name})")
deadline = time.monotonic() + args.plate_timeout
while time.monotonic() < deadline:
time.sleep(0.5)
if slot_state(handle) == wanted:
log(f"slot is {wanted.name}")
time.sleep(1.0) # let the plate settle before anything moves
return
raise MeasurementError(f"timed out waiting for the slot to read {wanted.name}")
def confirm(instruction, args):
"""For devices that cannot sense the plate: a human has to say it is ready."""
if args.assume_ready:
log(f"{instruction} (skipped: --assume-ready)")
return
if not sys.stdin.isatty():
raise MeasurementError(
f"cannot confirm '{instruction}' without a terminal; "
"run interactively, or pass --assume-ready once it is done"
)
print(f"{instruction}, then press Enter... ", end="", file=sys.stderr, flush=True)
sys.stdin.readline()
def check_alignment(handle):
if not byonoy.device_parts_aligned_supported(handle):
return
rc, aligned = byonoy.get_device_parts_aligned(handle)
check(rc, "get_device_parts_aligned")
if not aligned:
raise MeasurementError("device parts are not aligned; seat the device correctly and retry")
# --- one function per modality ----------------------------------------------
# Each returns a list of partial CSV rows; main() adds the device columns.
def measure_abs96(handle, args):
if not byonoy.abs96_available_wavelengths_supported(handle):
raise MeasurementError("device cannot report its absorbance wavelengths")
# Querying the wavelengths is also REQUIRED before a single-wavelength
# initialise: without it, the initialise returns INVALID_ARGUMENT.
rc, available = byonoy.abs96_get_available_wavelengths(handle)
check(rc, "abs96_get_available_wavelengths")
log(f"available wavelengths: {available}")
wavelengths = args.wavelength or available
unknown = [w for w in wavelengths if w not in available]
if unknown:
raise MeasurementError(f"wavelength(s) {unknown} not available on this device")
reference = args.reference_wavelength or 0
if reference and reference not in available:
raise MeasurementError(f"reference wavelength {reference} not available on this device")
mode = "rapid" if args.rapid else "standard"
# Initialise with the slot empty, then measure with the plate in.
wait_for_slot(handle, byonoy.DeviceSlotState.EMPTY, "Remove any plate from the device", args)
if len(wavelengths) == 1:
cfg = byonoy.Abs96SingleMeasurementConfig()
cfg.sample_wavelength = wavelengths[0]
cfg.reference_wavelength = reference # 0 = no reference wavelength
cfg.rapid_mode = args.rapid
log("initialising (slot must be empty)...")
check(byonoy.abs96_initialize_single_measurement(handle, cfg), "abs96_initialize_single_measurement")
wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the plate", args)
check_alignment(handle)
log(f"measuring at {wavelengths[0]} nm...")
rc, values = byonoy.abs96_single_measure(handle, cfg)
check(rc, "abs96_single_measure")
per_wavelength = [values]
else:
if reference:
raise MeasurementError("a reference wavelength needs a single --wavelength")
cfg = byonoy.Abs96MultipleMeasurementConfig()
cfg.sample_wavelengths = list(wavelengths)
cfg.rapid_mode = args.rapid
log("initialising (slot must be empty)...")
check(byonoy.abs96_initialize_multiple_measurement(handle, cfg), "abs96_initialize_multiple_measurement")
wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the plate", args)
check_alignment(handle)
log(f"measuring at {', '.join(map(str, wavelengths))} nm...")
rc, per_wavelength = byonoy.abs96_multiple_measure(handle, cfg)
check(rc, "abs96_multiple_measure")
if len(per_wavelength) != len(wavelengths):
raise MeasurementError(
f"expected {len(wavelengths)} result sets, got {len(per_wavelength)}"
)
rows = []
for wavelength, values in zip(wavelengths, per_wavelength):
for well, value in enumerate(values):
rows.append({
"modality": "abs96",
"mode": mode,
"wavelength_nm": wavelength,
"reference_wavelength_nm": reference or "",
"well_index": well,
"well": well_name(well),
"value": value,
"unit": "OD",
})
return rows
def measure_absone(handle, args):
if not byonoy.absone_available_wavelength_supported(handle):
raise MeasurementError("device cannot report its absorbance wavelength")
rc, wavelength = byonoy.absone_get_available_wavelength(handle)
check(rc, "absone_get_available_wavelength")
if args.wavelength and args.wavelength != [wavelength]:
raise MeasurementError(f"this device measures at {wavelength} nm only")
wait_for_slot(handle, byonoy.DeviceSlotState.EMPTY, "Remove any cuvette from the device", args)
log("initialising (slot must be empty)...")
check(byonoy.absone_initialize_measurement(handle, wavelength), "absone_initialize_measurement")
# absone_measure can block indefinitely instead of failing, so confirm the
# initialise took effect before calling it.
if byonoy.absone_is_initialized_supported(handle):
rc, initialized = byonoy.absone_is_initialized(handle)
check(rc, "absone_is_initialized")
if not initialized:
raise MeasurementError("initialise reported success but the device is not initialised")
wait_for_slot(handle, byonoy.DeviceSlotState.OCCUPIED, "Insert the cuvette", args)
check_alignment(handle)
log(f"measuring at {wavelength} nm...")
rc, value = byonoy.absone_measure(handle, wavelength)
check(rc, "absone_measure")
# A single cuvette: one row, no well index.
return [{"modality": "absone", "wavelength_nm": wavelength, "well_index": "", "well": "",
"value": value, "unit": "OD"}]
def luminescence_like(handle, args, modality, config_class, mode_enum, measure, wells, extra=None):
"""Shared by lum96 and flu96: pick a mode, select every well, measure."""
cfg = config_class()
cfg.mode = getattr(mode_enum, args.mode.upper()) # always set explicitly
cfg.selected_wells = [True] * wells # REQUIRED: a fresh config selects nothing
if extra:
extra(cfg)
state = slot_state(handle)
if state is not None:
log(f"slot reports {state.name}")
confirm("Make sure the plate is in place", args)
check_alignment(handle)
integration = {"rapid": "100 ms", "sensitive": "2 s"}[args.mode]
if modality == "lum96":
timing = {"rapid": "about 10 s", "sensitive": "about 60 s"}[args.mode] + " for all wells"
else:
timing = f"{integration} integration; time grows with the number of columns used"
log(f"measuring {wells} wells, mode {args.mode} ({timing}; Ctrl-C will not interrupt it)...")
started = time.monotonic()
rc, values = measure(handle, cfg)
check(rc, f"{modality}_measure")
log(f"done in {time.monotonic() - started:.1f} s")
if len(values) != wells:
raise MeasurementError(f"expected {wells} values, got {len(values)}")
return [
{
"modality": modality,
"mode": args.mode,
"well_index": well,
"well": well_name(well),
"value": value,
"unit": "RLU" if modality == "lum96" else "RFU",
}
for well, value in enumerate(values)
]
def measure_lum96(handle, args):
return luminescence_like(handle, args, "lum96", byonoy.Lum96MeasurementConfig,
byonoy.Lum96IntegrationMode, byonoy.lum96_measure, 96)
def measure_flu96(handle, args):
if not byonoy.flu96_available_filter_sets_supported(handle):
raise MeasurementError("device cannot report its filter sets")
rc, filter_sets = byonoy.flu96_get_available_filter_sets(handle)
check(rc, "flu96_get_available_filter_sets")
if not filter_sets:
raise MeasurementError("device reports no filter sets")
for fs in filter_sets:
log(f"filter set {fs.index}: excitation {fs.excitation_wavelength_lower_bound}-"
f"{fs.excitation_wavelength_upper_bound} nm, emission "
f"{fs.measurement_wavelength_lower_bound}-{fs.measurement_wavelength_upper_bound} nm")
if args.filter_set is None:
chosen = filter_sets[0]
else:
matches = [fs for fs in filter_sets if fs.index == args.filter_set]
if not matches:
raise MeasurementError(f"filter set {args.filter_set} not available on this device")
chosen = matches[0]
def set_filter(cfg):
cfg.filter_set_index = chosen.index
rows = luminescence_like(handle, args, "flu96", byonoy.Flu96MeasurementConfig,
byonoy.Flu96IntegrationMode, byonoy.flu96_measure, 96, set_filter)
for row in rows:
row["filter_set_index"] = chosen.index
row["excitation_nm"] = (f"{chosen.excitation_wavelength_lower_bound}-"
f"{chosen.excitation_wavelength_upper_bound}")
row["emission_nm"] = (f"{chosen.measurement_wavelength_lower_bound}-"
f"{chosen.measurement_wavelength_upper_bound}")
return rows
MEASURE = {
"abs96": measure_abs96,
"absone": measure_absone,
"lum96": measure_lum96,
"flu96": measure_flu96,
}
# --- output -----------------------------------------------------------------
def write_csv(rows, path):
# Rows are only written once the measurement has fully succeeded, so a
# failed run never leaves a half-written file behind.
out = open(path, "w", newline="", encoding="utf-8") if path else sys.stdout
try:
writer = csv.DictWriter(out, fieldnames=CSV_COLUMNS, restval="", lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
finally:
if path:
out.close()
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--modality", choices=sorted(MEASURE), help="default: what the device supports")
p.add_argument("--serial", help="serial number of the device to use when several are attached")
p.add_argument("--output", help="CSV file to write (default: stdout)")
p.add_argument("--mode", choices=INTEGRATION_MODES, default="rapid",
help="luminescence/fluorescence integration mode (default: rapid); "
"ULTRA_SENSITIVE (~10 min) and CUSTOM are deliberately not offered")
p.add_argument("--wavelength", type=int, action="append",
help="absorbance wavelength in nm; repeat for several (default: all available)")
p.add_argument("--reference-wavelength", type=int, help="abs96 reference wavelength in nm")
p.add_argument("--rapid", action="store_true", help="abs96 rapid mode")
p.add_argument("--filter-set", type=int, help="flu96 filter set index (default: the first)")
p.add_argument("--plate-timeout", type=float, default=120.0,
help="seconds to wait for a plate to be inserted or removed (default: 120)")
p.add_argument("--assume-ready", action="store_true",
help="do not ask for confirmation on devices that cannot sense the plate")
return p.parse_args()
def main():
args = parse_args()
handle = None
try:
handle = open_device(args.serial)
info = preflight(handle)
supported = detect_modality(handle)
modality = args.modality or supported[0]
if modality not in supported:
raise MeasurementError(f"this device does not support {modality}")
rows = MEASURE[modality](handle, args)
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
for row in rows:
row.update({
"timestamp_utc": timestamp,
"serial_number": info.sn,
"ref_no": info.ref_no,
"firmware_version": info.version,
"device_type": info.type.name,
})
write_csv(rows, args.output)
log(f"wrote {len(rows)} rows" + (f" to {args.output}" if args.output else ""))
return 0
except MeasurementError as e:
log(f"error: {e}")
return 1
finally:
if handle is not None:
byonoy.free_device(handle) # always release the device
if __name__ == "__main__":
sys.exit(main())
The CSV
One row per value, in long format: the same sixteen columns for every device, so files from different instruments can be concatenated and filtered without reshaping. Columns that do not apply to a modality are left empty.
| Column | Contents |
|---|---|
timestamp_utc |
when the measurement finished, ISO 8601 |
serial_number, ref_no, firmware_version, device_type |
from the device information call — enough to trace every row back to an instrument |
modality |
abs96, absone, lum96 or flu96 |
mode |
rapid/sensitive for luminescence and fluorescence; rapid/standard for abs96; empty for absone |
wavelength_nm, reference_wavelength_nm |
absorbance only |
filter_set_index, excitation_nm, emission_nm |
fluorescence only; the filter bands as lower-upper |
well_index |
0-based index into the result, as returned by the library; empty for absone |
well |
the plate position (A1 … H12); empty for absone |
value |
the reading, exactly as returned |
unit |
OD for absorbance, RLU for luminescence, RFU for fluorescence |
timestamp_utc,serial_number,ref_no,firmware_version,device_type,modality,mode,wavelength_nm,reference_wavelength_nm,filter_set_index,excitation_nm,emission_nm,well_index,well,value,unit
2026-09-30T16:31:43+00:00,SN1,REF1,8,Absorbance96,abs96,standard,492,,,,,0,A1,0.145,OD
2026-09-30T16:31:44+00:00,SN1,REF1,41,Luminescence96,lum96,rapid,,,,,,13,B2,-35.0,RLU
Well names follow the row-major order described in the SDK guide; units are explained under Units.
A multi-wavelength abs96 run gives 96 rows per wavelength. With --output,
rows are written only after the measurement has succeeded; a failed run writes
nothing and leaves any existing file as it was.
7. Prototyping without a device
The SDK has no simulator, but for prototyping — building a UI, a pipeline or a
test suite before hardware is on your desk — it is enough to have the API
answer with plausible values. Save this as byonoy_mock.py next to your code.
It patches the installed module in place, so the code under development stays
exactly as it will run against a real device:
"""A simulated Byonoy device, for prototyping without hardware.
Import it once, before anything else touches the SDK:
import byonoy_mock # patches byonoy_devices in place
import byonoy_devices as byonoy # your code, unchanged
It needs the real wheel installed. Enums, config classes and their argument
checks stay the real ones; only the calls that would talk to a device are
replaced. Pick the simulated device with an environment variable:
BYONOY_MOCK_DEVICE=Absorbance96|AbsorbanceOne|Luminescence96|Fluorescence96
Values are plausible, not realistic: they have the right shape and rough
magnitude, nothing more. Measurements return instantly.
"""
import math
import os
import random
import byonoy_devices as byonoy # internal variant: import byonoy_devices_internal as byonoy
OK = byonoy.ErrorCode.NO_ERROR
DEVICES = ["Absorbance96", "AbsorbanceOne", "Luminescence96", "Fluorescence96"]
_name = os.environ.get("BYONOY_MOCK_DEVICE", "Luminescence96")
if _name not in DEVICES:
raise SystemExit(f"BYONOY_MOCK_DEVICE={_name!r} is not simulated; use one of: {', '.join(DEVICES)}")
DEVICE = byonoy.DeviceTypes[_name]
# Discovery reports the type from the USB IDs alone; every Absorbance One is
# listed as AbsorbanceOneOr96 there, as with the real library.
DISCOVERED = byonoy.DeviceTypes.AbsorbanceOneOr96 if _name == "AbsorbanceOne" else DEVICE
SERIAL = "MOCK-0001"
PRODUCT_IDS = {"Absorbance96": 0x1199, "AbsorbanceOne": 0x106A, "Luminescence96": 0x119B,
"Fluorescence96": 0x12F2}
_open = set() # handles currently open
_state = {"initialized": False, "plate": False, "wavelengths_queried": False}
def _device():
d = byonoy.Device()
d.type, d.sn, d.vid, d.pid = DISCOVERED, SERIAL, 0x16D0, PRODUCT_IDS.get(DEVICE.name, 0)
return d
def _is(*types):
return lambda handle: DEVICE.name in types
def _call(fn, supported=lambda handle: True, action=False):
"""Wrap a mocked call: reject unknown handles and unsupported operations.
Calls that produce a value return (ErrorCode, value); actions
(action=True) return the ErrorCode alone, as in the real module.
"""
def wrapper(handle, *args):
if handle not in _open:
rc = byonoy.ErrorCode.INVALID_ARGUMENT
elif not supported(handle):
rc = byonoy.ErrorCode.UNSUPPORTED_OPERATION
else:
return fn(handle, *args)
return rc if action else (rc, None)
return wrapper
# --- discovery and lifecycle ------------------------------------------------
def open_device(device):
if _open:
return byonoy.ErrorCode.DEVICE_ALREADY_OPEN, 0
_open.add(1)
return OK, 1
def free_device(handle):
_open.discard(handle)
_state.update(initialized=False, plate=False, wavelengths_queried=False)
def get_device_information(handle):
info = byonoy.DeviceInfo()
info.type, info.sn, info.ref_no, info.version = DEVICE, SERIAL, "MOCK-REF", "0.0.0-mock"
return OK, info
# --- plate handling ---------------------------------------------------------
# Absorbance 96: the slot reads EMPTY until the measurement is initialised,
# then OCCUPIED, as if the operator had inserted the plate on cue. The
# Absorbance One, like the real one, cannot sense its slot.
def _slot(handle):
return OK, byonoy.DeviceSlotState.OCCUPIED if _state["plate"] else byonoy.DeviceSlotState.EMPTY
def _initialize(handle, *_):
_state.update(initialized=True, plate=True)
return OK
def _initialize_single(handle, cfg):
# Like the real library: fails until the wavelengths have been queried.
if not _state["wavelengths_queried"]:
return byonoy.ErrorCode.INVALID_ARGUMENT
return _initialize(handle)
def _wavelengths(handle):
_state["wavelengths_queried"] = True
return OK, [405, 450, 492, 620] if DEVICE.name == "Absorbance96" else [450]
# --- measurements -----------------------------------------------------------
def _od(well):
"""A dilution series: absorbance rises across the columns, A1 lowest."""
return round(0.045 + 0.1 * (well % 12) + random.gauss(0, 0.005), 4)
def _abs96_single(handle, cfg):
if not _state["initialized"]:
return byonoy.ErrorCode.NOT_INITIALIZED, [0.0] * 96
return OK, [_od(i) for i in range(96)]
def _abs96_multiple(handle, cfg):
if not _state["initialized"]:
return byonoy.ErrorCode.NOT_INITIALIZED, []
return OK, [[_od(i) for i in range(96)] for _ in cfg.sample_wavelengths]
def _absone(handle, wavelength):
if not _state["initialized"]:
return byonoy.ErrorCode.NOT_INITIALIZED, math.nan
return OK, round(0.2 + random.gauss(0, 0.005), 4)
def _luminescence(handle, cfg):
# Background-corrected whole numbers around zero; shorter integration is noisier.
sigma = {"RAPID": 130.0, "SENSITIVE": 40.0}.get(cfg.mode.name, 40.0)
return OK, [float(round(random.gauss(0, sigma))) if s else 0.0 for s in cfg.selected_wells]
def _fluorescence(handle, cfg):
return OK, [round(150 + random.gauss(0, 15), 1) if s else math.nan for s in cfg.selected_wells]
def _filter_sets(handle):
bands = [(470, 490, 510, 540), (530, 550, 580, 620)]
sets = []
for index, (ex_lo, ex_hi, em_lo, em_hi) in enumerate(bands):
fs = byonoy.Flu96FilterSet()
fs.index = index
fs.excitation_wavelength_lower_bound, fs.excitation_wavelength_upper_bound = ex_lo, ex_hi
fs.measurement_wavelength_lower_bound, fs.measurement_wavelength_upper_bound = em_lo, em_hi
sets.append(fs)
return OK, sets
abs96 = _is("Absorbance96")
absone = _is("AbsorbanceOne")
# As on the real devices, each absorbance device also answers True to the other
# one's wavelength predicate; only *_measurement_supported decides the modality.
either_absorbance = _is("Absorbance96", "AbsorbanceOne")
_MOCKS = {
"available_devices": lambda: [_device()],
"available_devices_count": lambda: 1,
"open_device": open_device,
"free_device": free_device,
"device_open": lambda handle: handle in _open,
"get_device_information": _call(get_device_information),
"get_device_status": _call(lambda h: (OK, byonoy.DeviceState.OK)),
"get_device_error": _call(lambda h: (OK, 0)),
"device_temperature_supported": lambda h: DEVICE.name != "AbsorbanceOne",
"get_device_temperature": _call(lambda h: (OK, round(24.0 + random.gauss(0, 0.2), 2))),
"device_humidity_supported": lambda h: False,
"device_uptime_supported": lambda h: True,
"get_device_uptime": _call(lambda h: (OK, 3600)),
"device_slot_status_supported": abs96,
"get_device_slot_status": _call(_slot, abs96),
"device_parts_aligned_supported": abs96,
"get_device_parts_aligned": _call(lambda h: (OK, True), abs96),
"device_readout_orientation_supported": lambda h: False,
"device_update_supported": lambda h: False,
"abs96_measurement_supported": abs96,
"abs96_available_wavelengths_supported": either_absorbance,
"abs96_modules_supported": lambda h: False,
"abs96_get_available_wavelengths": _call(_wavelengths, either_absorbance),
"abs96_initialize_single_measurement": _call(_initialize_single, abs96, action=True),
"abs96_initialize_multiple_measurement": _call(_initialize, abs96, action=True),
"abs96_single_measure": _call(_abs96_single, abs96),
"abs96_multiple_measure": _call(_abs96_multiple, abs96),
"absone_measurement_supported": absone,
"absone_available_wavelength_supported": either_absorbance,
"absone_get_available_wavelength": _call(lambda h: (OK, 450), absone),
"absone_is_initialized_supported": absone,
"absone_is_initialized": _call(lambda h: (OK, _state["initialized"]), absone),
"absone_initialize_measurement": _call(_initialize, absone, action=True),
"absone_measure": _call(_absone, absone),
"lum96_measurement_supported": _is("Luminescence96"),
"lum96_measure": _call(_luminescence, _is("Luminescence96")),
"flu96_measurement_supported": _is("Fluorescence96"),
"flu96_available_filter_sets_supported": _is("Fluorescence96"),
"flu96_get_available_filter_sets": _call(_filter_sets, _is("Fluorescence96")),
"flu96_measure": _call(_fluorescence, _is("Fluorescence96")),
}
for _name, _fn in _MOCKS.items():
setattr(byonoy, _name, _fn)
Use it by importing it first:
import byonoy_mock # remove this line to talk to real hardware
import byonoy_devices as byonoy
Or leave your program untouched and load the mock from outside it with this
small runner, saved as run_with_mock.py:
"""Run a Python program against the simulated device instead of real hardware.
python run_with_mock.py measure_to_csv.py --assume-ready --output plate.csv
"""
import runpy
import sys
import byonoy_mock # noqa: F401 (patches byonoy_devices before the program imports it)
sys.argv = sys.argv[1:]
runpy.run_path(sys.argv[0], run_name="__main__")
Here it runs the complete example against a simulated Absorbance 96:
BYONOY_MOCK_DEVICE=Absorbance96 ./.venv/bin/python run_with_mock.py measure_to_csv.py --output mock-plate.csv
$env:BYONOY_MOCK_DEVICE='Absorbance96'; .\.venv\Scripts\python.exe run_with_mock.py measure_to_csv.py --output mock-plate.csv
Every simulated device except the Absorbance 96 needs --assume-ready (or a
terminal) to get past the example's plate prompts, just as the real ones do.
Keep simulated and real output in differently named files, so a stale mock
file is never mistaken for a real result.
What the simulated device does:
- one device, serial number
MOCK-0001, of the type named byBYONOY_MOCK_DEVICE(defaultLuminescence96; an unknown name stops with the list of valid ones); its*_supportedpredicates answer for that type only, and calls for any other modality returnUNSUPPORTED_OPERATION - the return shapes of the real module —
(ErrorCode, value)tuples, bareErrorCodes for actions, realDeviceInfoandFlu96FilterSetobjects DEVICE_ALREADY_OPENon a second open, andINVALID_ARGUMENTfor a handle that was freed- Absorbance 96: the slot reads
EMPTYuntil the measurement is initialised andOCCUPIEDafter, as if the plate were inserted on cue. As on the real device, a single-wavelength initialise fails withINVALID_ARGUMENTuntil the wavelengths have been queried, and measuring before initialising givesNOT_INITIALIZED. Values rise across the columns (about 0.05 in column 1 to 1.15 in column 12), so a mix-up in well order is easy to spot - Absorbance One: like the real one it cannot sense its slot (so the example
needs
--assume-readyor a terminal), measures at 450 nm, and is listed by discovery asAbsorbanceOneOr96 - as on the real devices, each absorbance device also answers
Trueto the other one's wavelength predicate - luminescence: whole-number noise around zero, with the spread given under
Is my reading plausible?, and
0for deselected wells - fluorescence: two filter sets, values around 150, and
NaNfor deselected wells
What it does not do: take time, fail the way hardware fails, or produce values
that mean anything. Measurements return instantly, so it will not show you a
timeout that is too short, a UI that freezes during a 60-second read, or a
call that never returns. Combined devices are not simulated, the Absorbance
96's module calls (abs96_modules_supported and friends) and firmware update
answer as unsupported, and temperature is reported by every simulated device
except the Absorbance One. Use it to get the plumbing right; validate against a real device.
The mock replaces only the public calls. With the internal variant, change the import at its top; calls it does not replace still go to the real library and will find no device.
8. Verify your setup
Confirms install and hardware together, and says plainly what it did and did
not check. Expects a device attached. Save it as verify_setup.py
(substitute byonoy_devices_internal in the import if that is the variant you
installed):
"""Check the install and the attached device, and say plainly what was checked.
python -u verify_setup.py
"""
import sys
import byonoy_devices as byonoy # internal variant: import byonoy_devices_internal as byonoy
OK = byonoy.ErrorCode.NO_ERROR
def fail(message):
sys.exit(f"FAILED: {message}")
def check(rc, what):
if rc != OK:
hint = ""
if rc == byonoy.ErrorCode.DEVICE_COMMUNICATION_FAILURE:
hint = " — another program holding the device? on Linux: permissions?"
elif rc == byonoy.ErrorCode.DEVICE_OPERATION_FAILED:
hint = " — if every measurement fails like this, see 'When the device stops measuring'"
fail(f"{what} returned {rc.name} ({hex(rc.value)}){hint}")
v = byonoy.library_version()
print(f"library {v.major}.{v.minor}.{v.patch}")
devices = byonoy.available_devices()
print(f"{len(devices)} device(s)")
if not devices:
fail("no device found — attach one (on Linux, also check permissions)")
rc, handle = byonoy.open_device(devices[0])
check(rc, "open_device")
try:
rc, info = byonoy.get_device_information(handle)
check(rc, "get_device_information")
print(f"type {info.type.name} | sn {info.sn} | fw {info.version}")
rc, state = byonoy.get_device_status(handle)
check(rc, "get_device_status")
rc, device_error = byonoy.get_device_error(handle)
check(rc, "get_device_error")
print(f"status {state.name} | device error {device_error} ({device_error:#x})")
if state != byonoy.DeviceState.OK or device_error:
fail("the device reports a problem; do not measure — report status and error to Byonoy support")
# Measure too: open and status both succeed on a device that can no longer
# measure. RAPID on all wells keeps this to about 10 s.
if byonoy.lum96_measurement_supported(handle):
cfg = byonoy.Lum96MeasurementConfig()
cfg.mode = byonoy.Lum96IntegrationMode.RAPID
cfg.selected_wells = [True] * 96
rc, values = byonoy.lum96_measure(handle, cfg)
check(rc, "lum96_measure")
print(f"measured {len(values)} wells (luminescence 96)")
elif byonoy.flu96_measurement_supported(handle):
rc, filter_sets = byonoy.flu96_get_available_filter_sets(handle)
check(rc, "flu96_get_available_filter_sets")
if not filter_sets:
fail("the device reports no filter sets")
cfg = byonoy.Flu96MeasurementConfig()
cfg.filter_set_index = filter_sets[0].index
cfg.mode = byonoy.Flu96IntegrationMode.RAPID
cfg.selected_wells = [True] * 96
rc, values = byonoy.flu96_measure(handle, cfg)
check(rc, "flu96_measure")
print(f"measured {len(values)} wells (fluorescence 96)")
elif byonoy.abs96_measurement_supported(handle):
# Initialising needs an empty slot, the normal idle state, so it can be
# checked safely; measuring needs a plate and is not checked here.
rc, wavelengths = byonoy.abs96_get_available_wavelengths(handle)
check(rc, "abs96_get_available_wavelengths")
rc, slot = byonoy.get_device_slot_status(handle)
check(rc, "get_device_slot_status")
if slot != byonoy.DeviceSlotState.EMPTY:
print(f"NOT MEASURED: slot is {slot.name}; remove the plate to check the initialise")
else:
cfg = byonoy.Abs96SingleMeasurementConfig()
cfg.sample_wavelength = wavelengths[0]
cfg.reference_wavelength = 0
check(byonoy.abs96_initialize_single_measurement(handle, cfg),
"abs96_initialize_single_measurement")
print(f"initialise at {wavelengths[0]} nm OK; NOT MEASURED (needs a plate)")
else:
print("NOT MEASURED: absorbance needs a cuvette in the slot, so this script "
"checked communication and status only")
finally:
byonoy.free_device(handle)
print("OK")
Run it under a time limit, since a measurement on a faulty device can block indefinitely:
./.venv/bin/python with_timeout.py 120 ./.venv/bin/python -u verify_setup.py
.\.venv\Scripts\python.exe with_timeout.py 120 .\.venv\Scripts\python.exe -u verify_setup.py
It ends with OK only if the device opened, reported status OK and device
error 0, and — on luminescence and fluorescence devices — completed a
measurement. On an Absorbance 96 Automate with an empty slot it runs an
initialise, which needs no plate. On absorbance devices it prints
NOT MEASURED: a meaningful absorbance check needs a plate or cuvette, so use
the complete example for that.