Skip to content

Add Agilent Bravo liquid handler backend - #1213

Open
kelsorj wants to merge 9 commits into
PyLabRobot:mainfrom
kelsorj:agilent-bravo
Open

Add Agilent Bravo liquid handler backend#1213
kelsorj wants to merge 9 commits into
PyLabRobot:mainfrom
kelsorj:agilent-bravo

Conversation

@kelsorj

@kelsorj kelsorj commented Aug 21, 2026

Copy link
Copy Markdown

Summary

Adds support for the Agilent Bravo — a fixed-head liquid handler with an integrated plate gripper — covering four hardware generations plus a simulation controller that needs no instrument.

Generation Connection Gripper
Darwin TCP 7613 (Gemini protocol) yes
Agile 7612 TCP 7612 (V11/Agile protocol) yes
Bravo SRT TCP 7612 no
Agile (legacy) serial, or TCP 10000 yes
Simulation none configurable

from pylabrobot.agilent import AgilentBravoBackend, Bravo, BravoDeck

The constraint that shapes the API

The Bravo's barrels move as one unit on a single plunger drive. Every operation it can perform is therefore a contiguous rectangular block of head barrels, anchored at one of four corners.

That one rule governs the whole backend. A single well selects one barrel; a full column selects a column; the *96 methods use the whole head and honour gaps in the requested tips. A selection that isn't rectangular is rejected with a message saying what was asked for and what shape would work:

The requested selection is not a contiguous rectangle of barrels: 3 wells
spanning a 2x2 region leaves 1 gap. The head can address a full row, a full
column, a rectangular block, or a single barrel.

Because the plunger drive is shared, a multi-channel aspirate or dispense requires a uniform volume and flow rate. Non-uniform values are rejected rather than silently averaged.

Head geometry varies by installed head — 8x12 at 9 mm pitch, 16x24 at 4.5 mm, 8x1, 16x1 — so num_channels reports 8, 16, 96, or 384.

Layout

The driver lives at pylabrobot/agilent/bravo/, alongside the existing BenchCel, BioTek, and VSpin drivers, with the LiquidHandlerBackend subclass importing its base from pylabrobot.legacy.

This is a deliberate deviation from the driver guide's flat-driver guidance. The guide asks for one file and one plain class, and promotion to a package only when it genuinely helps. The Bravo spans two unrelated wire protocols across four hardware generations; flattening would produce single files of several thousand lines covering protocols that share nothing. Each module is scoped to a hardware concern — Gemini framing, per-axis commutation state machines, the V11 packet format.

The LiquidHandlerBackend base is imported from pylabrobot.legacy, where every existing liquid handler lives. The driver itself sits in the vendor-first tree with the other Agilent devices, since that appears to be the direction the codebase is moving.

Design notes

Transport. Bravo controllers are synchronous; pylabrobot.io is asynchronous. Rather than convert several thousand lines of controller code, a shared bridge submits each coroutine to the event loop that owns the connection and blocks the calling worker thread. All device I/O goes through pylabrobot.io.Socket and Serial.

The measured cost is ~70 µs per call (20,000 samples, low-end order statistics, stable across Python 3.9 and 3.11). Darwin's axis state machines poll at 5 Hz with an explicit sleep, and a full multi-axis homing cannot exceed roughly 1,000 polls before its own timeouts fire — so the bridge accounts for well under half a percent of a sequence dominated by deliberate waiting.

Deck. BravoDeck exposes the instrument's nine locations as a PyLabRobot Deck whose site origins come from the machine's taught X/Y/Z, so the model reflects the real instrument rather than a nominal layout. That means an instrument already taught works without a separate calibration step.

Capability flags. num_arms and head96_installed derive from the installed model, so a gripperless SRT reports no arm and PyLabRobot rejects plate movement before the backend is reached.

Hardware verification — please read this before merging

This is stated precisely because the split matters:

  • Exercised against real instruments: the wire protocols and the controller layers, on Darwin, Agile 7612, SRT, and legacy Agile hardware.
  • Not yet exercised on hardware: the pylabrobot.io transport bridge, the deck mapping, and AgilentBravoBackend itself.

setup() logs a warning saying exactly that, per the driver guide's requirement for unverified drivers, and the registry entry is status: wip. The warning comes off per generation as each is bench-verified through PyLabRobot.

One specific unknown worth a reviewer's attention: BravoDeck translates a PyLabRobot Plate or TipRack into the instrument's well grid — pitch, offset, and the sign convention for where A1 sits. The convention chosen is pinned by a test and documented in deck/resource.py, but it cannot be confirmed without an instrument. Under default teachpoints some deck sites are unreachable for a 96-well plate, which may indicate the convention needs flipping. If it does, exactly one test fails and points at the fix.

Known limitations

  • 384-channel heads work through the per-channel path. They cannot use the *96 path, which requires exactly 96 tips. Generalising LiquidHandler to N channels looks like separate work; happy to take it on.
  • The SRT is gripperless. It reports num_arms = 0, so PyLabRobot's own error fires on plate movement.
  • Returning tips to a still-full box is constrained by the instrument: a return is legal only when the block's edge aligns with the still-occupied band, so row, column, and all_barrels round-trip cleanly while a single-barrel return is rejected with an explanation. Dropping to a trash is unconstrained.

Protocol provenance and safety

Neither wire protocol is vendor documentation. Both were recovered by observing traffic between Agilent VWorks and an instrument, and the modules say so. Neither carries authentication or encryption, so anyone with network access to an instrument can command it.

The implementation derives from pyBravo, my open-source Bravo control project, where the protocols were reverse-engineered and exercised on real instruments. That project remains independently maintained; this contribution brings the hardware layer to PyLabRobot.

pyBravo is an independent project, not affiliated with or endorsed by Agilent Technologies; product names identify the hardware the software runs on. Agilent has confirmed in writing that they do not object to its release.

The Bravo can break labware, crush a pipette head, or injure a hand. The guide's safety section and the module docstrings say so, and the simulation controller exists so a protocol can be dry-run with no instrument attached.

Test plan

  • make test — 813 tests for this driver, full suite green
  • make lint, make format-check — clean
  • make typecheck — clean across 791 files
  • ruff check --select E501 --line-length 100 — clean
  • Hello-world guide executes end to end against the simulation controller, 12 of 13 code cells (the omitted one connects to a real instrument)
  • Device registry tests — 230 passed
  • No reflective attribute access anywhere in the package
  • Bench validation of the transport bridge, deck mapping, and backend on real hardware

Correctness is pinned by golden-frame tests: the full ordered controller-call sequence for every homing routine, move, jog, grip, and state-machine task is checked in as a fixture, so a change in packet content, field order, or phase ordering fails immediately. Behaviour that depends on the controller generation, or that never reaches a controller call, is pinned by direct tests instead — captured sequences structurally cannot see those.

make docs-check currently fails on main with pre-existing autosummary stub warnings in pylabrobot.resources.biorad and corning, unrelated to this change. These commits add no new warnings.

Size

53,000 lines across 112 files, all additive except one export line in pylabrobot/agilent/__init__.py and the documentation entries. I'm conscious that's a lot to review at once. If it would help, I'm glad to split it — the natural seam is by hardware generation, with Darwin and the Agile family as separate PRs on a shared base. Happy to reshape it however is most useful.

Axis, speed level, and head type are Literal types with internal tables
mapping them to firmware wire codes. Head geometry covers the 8x12, 16x24,
8x1, and 16x1 heads, and head-mode normalisation reduces every selection to
a contiguous rectangular block of barrels at one of four anchor corners.

BravoMachineConfig carries the per-machine head, safety, gripper, and
per-axis settings a Bravo needs.
Bravo controllers are synchronous; PyLabRobot's io layer is asynchronous.
AsyncTransportBase bridges them: a controller running inside asyncio.to_thread
submits its coroutine to the event loop that owns the connection and blocks
until it completes.

receive returns b"" on timeout; receive_exact raises TimeoutError. The outer
bound is the cumulative ceiling on a call, since per-chunk timeouts do not sum.
Socket and serial implementations share the bridge so their semantics cannot
drift.
Two families. Gemini serves Darwin-generation firmware: an 8-byte
little-endian header carrying a sync word, protocol version, payload type,
and payload size, followed by packets or instructions. V11/Agile serves the
Agile, Agile 7612, and SRT generations, with CRC-8/SMBUS on the legacy
packet format and CRC-8/MAXIM on the 7612 format.

Neither protocol is vendor documentation; both were recovered by observing
traffic between Agilent VWorks and an instrument. Neither carries
authentication or encryption, so anyone with network access to the
instrument can command it.
BravoController is the interface every generation implements, taking an
already-connected transport and exposing initialize for the synchronous
post-connect handshake. AgileSrtController extends Agile7612Controller
extends AgileController, matching how each generation varies on the one
before.

Per-axis settings come from typed AxisConfig values rather than runtime
attribute probing. The SRT is gripperless and reports so through
has_gripper, rejecting gripper operations by model name.

Golden-frame tests pin the full command sequence for every homing routine,
move, jog, and grip, so a change in packet content, field order, or phase
ordering fails immediately.
Darwin axes are not initialised by a single firmware call. Each is driven
step by step through motor-state writes and polled reads, implemented as
per-axis state machines, which is what makes retry-on-regression possible.
Polling runs at 5 Hz with an explicit sleep between reads.

The W axis is millimetre-native on this generation and microlitre-native on
the Agile family, so ul_to_mm is declared on the controller interface and
overridden here.
The instrument addresses nine deck locations in a 3x3 grid, tracking which
labware sits at each, taught positions per location, and stack heights
including lid and nesting geometry.

BravoDeck exposes those nine locations as a PyLabRobot Deck whose site
origins come from the instrument's taught X/Y/Z, so the model reflects the
real machine rather than a nominal layout, and translates PyLabRobot
resources into the internal labware the motion layer consumes.

The well-grid translation's sign and frame convention is pinned by test but
not yet confirmed against an instrument; deck/resource.py documents which
sites are affected under default teachpoints.
Fourteen operations -- initialize, home, dock gripper, move to location,
aspirate, dispense, mix, tips on, tips off, pick and place, gripper teach
move, delid, relid, and scan stack height -- expressed as tasks the engine
runs step by step with support for abort, retry, and ignore.

This is where an operator request becomes the ordered sequence of axis moves
that accomplishes it safely: Z clearance before lateral transit, two-phase
approaches, tip-touch handling, and neighbour-footprint checks.

The W axis carries volumes on the Agile family and millimetres on Darwin, so
a volume is converted to controller-native units where it is combined with a
native position; park positions and offsets are millimetres and are never
converted.

Golden-frame tests pin the full controller-call sequence for 28 scenarios.
Behaviour that depends on the controller generation, or that never reaches a
controller call, is pinned by direct tests instead, since captured sequences
cannot see it.
The Bravo is a fixed-head pipettor: every barrel moves together and shares
one plunger drive. Every operation it can perform is therefore a contiguous
rectangular block of head barrels anchored at one of four corners. block.py
derives that block from the targeted wells and explains what shape would
work when a selection is not rectangular.

AgilentBravoBackend maps PyLabRobot operations onto it. A single well
selects one barrel, a full column selects a column, and the 96 methods use
the whole head, honouring gaps in the requested tips. A multi-channel
aspirate requires a uniform volume and flow rate because of the shared
plunger drive.

num_arms and head96_installed derive from the installed model, so a
gripperless SRT reports no arm and PyLabRobot rejects plate movement before
the backend is reached.

384-channel heads work through the per-channel path. They cannot use the 96
path, which requires exactly 96 tips.
Adds the API reference entry, a hello-world guide covering connection,
homing, the rectangular-block rule, tip handling, liquid handling, and plate
movement, and the device registry entry.

The guide runs end to end against the simulation controller with no hardware
attached.
Comment thread docs/_static/devices.json
Comment on lines +18 to +33
{
"id": "agilent-bravo",
"vendor": "Agilent",
"name": "Bravo",
"kind": "liquid handler",
"capabilities": [
"liquid handling",
"arm"
],
"status": "wip",
"api": "pylabrobot.agilent.AgilentBravoBackend",
"api_version": "v1",
"code_slug": "agilent/bravo",
"doc_slug": "agilent/bravo/hello-world",
"notes": "Four hardware generations (Darwin, Agile 7612, Bravo SRT, legacy Agile) plus a simulation controller; the protocol and controller layers are exercised against real instruments, but the PyLabRobot transport, deck mapping, and backend are not."
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you have an account on the PLR forum? you should add yourself as the device manager here (assuming you are interested in maintaining this)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rickwierenga I'm kelsorjsf on PLR forum.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants