1508 lines
62 KiB
Markdown
1508 lines
62 KiB
Markdown
# Byonoy Device Library — Python SDK
|
||
|
||
Read the **[SDK guide](README.md)** 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:
|
||
|
||
```bash
|
||
sudo apt update && sudo apt install -y python3-venv libhidapi-libusb0 # Debian, Ubuntu
|
||
sudo dnf install hidapi # Fedora
|
||
```
|
||
|
||
Then, on Linux and macOS alike:
|
||
|
||
```bash
|
||
python3 -m venv .venv
|
||
./.venv/bin/python -m pip install \
|
||
--index-url https://git.byonoy.com/api/packages/public/pypi/simple/ \
|
||
byonoy_devices
|
||
```
|
||
|
||
The same wheels are attached to each release on the
|
||
[releases page](https://git.byonoy.com/public/byonoy_devices_sdk/releases), for machines without access to the registry: download
|
||
the one matching your platform and Python version, and install the file
|
||
directly (`./.venv/bin/python -m pip install ./byonoy_devices-…whl`).
|
||
|
||
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](README.md#macos-and-windows) in the SDK guide.
|
||
|
||
On **Linux**, the device also needs permissions before you can open it — see
|
||
[Linux permissions](README.md#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`:
|
||
|
||
```powershell
|
||
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](#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):
|
||
|
||
```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:
|
||
|
||
```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
|
||
|
||
```python
|
||
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:` or `if rc != 0:`.** Neither raises. `rc == 0` is
|
||
> always `False` and `rc != 0` is always `True`, 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:
|
||
|
||
```python
|
||
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 entries `False`.
|
||
> 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 raises `TypeError`. Set `mode` explicitly too rather
|
||
> than relying on the default.
|
||
|
||
To select part of a plate, index the list in the
|
||
[well order](README.md#well-order) — row-major, so entry 0 is A1 and entry 12
|
||
is B1:
|
||
|
||
```python
|
||
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: `RAPID` 100 ms, `SENSITIVE` 2 s, `ULTRA_SENSITIVE` 20 s. A full plate
|
||
> takes roughly 30 × that — about 10 s, 60 s and **10 minutes**. Never end an
|
||
> `ULTRA_SENSITIVE` read early: on a test unit, stopping one after 9 minutes
|
||
> left the device unable to measure until it was power-cycled. `CUSTOM` must be
|
||
> at least 50 ms (`INVALID_ARGUMENT` otherwise) and has no upper limit; its
|
||
> duration follows the same formula (see the SDK guide's
|
||
> [timing notes](README.md#3-how-the-library-works)).
|
||
|
||
### Absorbance
|
||
|
||
Absorbance needs an initialise with the slot **empty**, then a measure with the
|
||
plate (or cuvette) **in**. For an Absorbance 96 Automate:
|
||
|
||
```python
|
||
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_wavelengths`
|
||
returned on this handle, so a single-wavelength initialise on a fresh handle
|
||
returns `INVALID_ARGUMENT` until 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_ERROR` and meaningless near-zero values. Poll
|
||
`get_device_slot_status` (`EMPTY` before initialising, `OCCUPIED` before
|
||
measuring), as the [complete example](#6-complete-example-measure-and-export-csv)
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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](README.md#3-how-the-library-works)). 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`:
|
||
|
||
```python
|
||
"""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
|
||
|
||
```python
|
||
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](README.md#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](README.md#1-which-variant-public-or-internal)
|
||
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:
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
```python
|
||
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` |
|
||
|
||
```bash
|
||
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.
|
||
|
||
```python
|
||
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](README.md#when-the-device-stops-measuring),
|
||
without unplugging anything:
|
||
|
||
```python
|
||
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:
|
||
|
||
```bash
|
||
./.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
|
||
```
|
||
|
||
```powershell
|
||
.\.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.
|
||
|
||
```python
|
||
#!/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](README.md#well-order); units are explained under
|
||
[Units](README.md#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:
|
||
|
||
```python
|
||
"""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:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
"""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](#6-complete-example-measure-and-export-csv)
|
||
against a simulated Absorbance 96:
|
||
|
||
```bash
|
||
BYONOY_MOCK_DEVICE=Absorbance96 ./.venv/bin/python run_with_mock.py measure_to_csv.py --output mock-plate.csv
|
||
```
|
||
|
||
```powershell
|
||
$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 by
|
||
`BYONOY_MOCK_DEVICE` (default `Luminescence96`; an unknown name stops with
|
||
the list of valid ones); its `*_supported` predicates answer for that type
|
||
only, and calls for any other modality return `UNSUPPORTED_OPERATION`
|
||
- the return shapes of the real module — `(ErrorCode, value)` tuples, bare
|
||
`ErrorCode`s for actions, real `DeviceInfo` and `Flu96FilterSet` objects
|
||
- `DEVICE_ALREADY_OPEN` on a second open, and `INVALID_ARGUMENT` for a handle
|
||
that was freed
|
||
- Absorbance 96: the slot reads `EMPTY` until the measurement is initialised
|
||
and `OCCUPIED` after, as if the plate were inserted on cue. As on the real
|
||
device, a single-wavelength initialise fails with `INVALID_ARGUMENT` until
|
||
the wavelengths have been queried, and measuring before initialising gives
|
||
`NOT_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](README.md#well-order) is
|
||
easy to spot
|
||
- Absorbance One: like the real one it cannot sense its slot (so the example
|
||
needs `--assume-ready` or a terminal), measures at 450 nm, and is listed by
|
||
discovery as `AbsorbanceOneOr96`
|
||
- as on the real devices, each absorbance device also answers `True` to the
|
||
other one's wavelength predicate
|
||
- luminescence: whole-number noise around zero, with the spread given under
|
||
[Is my reading plausible?](#is-my-reading-plausible), and `0` for
|
||
deselected wells
|
||
- fluorescence: two filter sets, values around 150, and `NaN` for 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):
|
||
|
||
```python
|
||
"""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:
|
||
|
||
```bash
|
||
./.venv/bin/python with_timeout.py 120 ./.venv/bin/python -u verify_setup.py
|
||
```
|
||
|
||
```powershell
|
||
.\.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](#6-complete-example-measure-and-export-csv) for that.
|