1502 lines
62 KiB
Markdown
1502 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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
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.
|