Skip to content

Add Agilent BenchCel 4R storage backend#1109

Open
c-reiter wants to merge 2 commits into
PyLabRobot:mainfrom
c-reiter:add-agilent-benchcel-4r
Open

Add Agilent BenchCel 4R storage backend#1109
c-reiter wants to merge 2 commits into
PyLabRobot:mainfrom
c-reiter:add-agilent-benchcel-4r

Conversation

@c-reiter

@c-reiter c-reiter commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Add Agilent BenchCel 4R storage backend

This PR adds an integration for the Agilent BenchCel 4R microplate handler, modeled as a PyLabRobot storage machine, plus an in-process mock TCP server that emulates the device's binary wire protocol for hardware-free development.

It follows the usual PLR driver structure: a reverse-engineered backend, a faithful mock server, hardware-free tests, and a user guide.

What's added

Backend — pylabrobot.storage.agilent.BenchCel4RBackend

  • Speaks the BenchCel's framed binary protocol over TCP/7612 (port/host configurable). Each application frame is [1 byte command_id][2 byte LE payload_length][payload].
  • Reverse-engineered from Agilent VWorks packet captures and live tests against a 4-stacker BenchCel (firmware 3.2.20.0). This is not vendor wire-protocol documentation; the public Agilent Quick Guide (G5400-90003) documents operation/safety but not the Ethernet protocol.
  • Modeled as an IncubatorBackend, since the BenchCel is open, sequential stacker storage. The firmware addresses whole stackers, so PLR racks track the expected plate order/content per stacker.
  • Implemented commands: home / home-motors (0x47/0x48), stacker load/unload (0x60/0x61), pick/downstack + place/upstack (0x62/0x63), move-to-target (0x65), relative jog (0x66), pneumatic stacker clamps (0x67), full-open/close robot grippers (0x6a), save teachpoint (0x73), labware-settings push + commit (0x7d/0x9f), stacker sensors (0x7e), current position (0x85), general/arm status (0x87), axis bounds (0x99), and device-error frames (0x02).
  • Decoded status helpers: request_stacker_sensors, request_all_stacker_sensors, request_arm_status, request_general_status, request_axis_bounds, request_current_position.
  • Optional source_ip to bind a specific local NIC on multi-homed control hosts.
  • No files are written on the host. The device has no known command to read teachpoints back; if you need to persist the numeric teachpoints you wrote, keep them in your own protocol/config.

Narrowed dangerously_ API

Only the one genuinely dangerous operation is prefixed: dangerously_open_stacker_grippers (0x67 open), because opening the pneumatic stacker clamps can release/drop a plate stack if it isn't physically supported. Everything else — home, move_to_stacker, downstack_plate/upstack_plate, load_stacker/unload_stacker, jog/move_x/move_z/rotate_theta, fully_open_grippers/fully_close_grippers, close_stacker_grippers, save_teachpoint, set_labware, and the Incubator transfer hooks — are regular methods. (An earlier revision prefixed nearly every motion method; that was narrowed to just the clamp-open command on review feedback.)

  • home_motors (0x47) drops the TCP control session while homing; the backend reconnects and polls 0x87 until the device is responsive again.

Factory + storage resource

  • BenchCel4R(name, host, ...) builds the backend plus four generic stacker racks and returns a plain Incubator (the BenchCel reuses PLR's only storage abstraction; no custom subclass). The name is required because every PLR Resource needs a unique name for the resource tree, serialization, and lookups — same convention as other device factories.
  • Transfers require a configured teachpoint. The BenchCel has no fixed take-in/drop-off position; the transfer point is a numeric VWorks teachpoint. loading_tray_teachpoint_id therefore defaults to None, and fetch_plate_to_loading_tray / take_in_plate raise a clear error if no teachpoint is configured (or passed per call via teachpoint_id=). The teachpoint must already be taught on the device (VWorks or save_teachpoint).
  • Incubator.loading_tray / loading_tray_location (a fixed Coordinate) is cosmetic only — PLR resource-tree/visualization bookkeeping. Actual robot motion is driven entirely by the teachpoint ID.

VWorks button → opcode mapping (confirmed from captures)

Dedicated packet captures of the VWorks stacker buttons confirm the reverse-engineered mapping byte-for-byte (examples for stacker 3 = zero-based target 0x02):

VWorks action Wire command Payload ACK
Downstack 0x62 (robot pick) 01 02 00 01 0x69 62
Upstack 0x63 (robot place) 01 02 00 01 0x69 63
Load 0x60 (stacker mech) 01 02 0x69 60 02
Unload 0x61 (stacker mech) 01 02 00 00 00 00 0x69 61 02
Open / Close clamps 0x67 <idx> 01 / <idx> 00 0x69 67

So 0x62/0x63 are the VWorks Downstack/Upstack tasks (per-plate robot pick/place at a stacker target; with a teachpoint target they're a plain pick/place there), and 0x60/0x61 are the separate whole-stacker Load/Unload mechanism commands behind the Load/Unload buttons — now exposed as load_stacker/unload_stacker (renamed from load_plate/unload_plate to reflect that they drive the stacker, not a single plate). A regression test (test_stacker_primitives_match_vworks_captures) locks in these exact wire bytes.

One command observed in a capture but not yet confidently mapped is 0x71 (payload 00 00, ACK 0x69 71), seen once just before a stacker Load — likely a per-stacker init/reset. It is intentionally not exposed until more samples pin it down.

Calculation-based labware integration — benchcel_labware.py

Rather than bundling per-catalog XML profiles, BenchCel geometry is calculated from PLR Plate dimensions:

  • calculate_benchcel_labware_settings(plate, ...) derives StackingThickness, RobotGripperOffset, StackerGripperOffset, and SensorOffset from the plate's outside dimensions, with conservative defaults and per-field overrides.
  • apply_benchcel_labware_settings(plate, ...) sets plate.preferred_pickup_location (mapping RobotGripperOffset → pickup-z) and validates that the PLR plate height matches.
  • BenchCel4R(..., labware=plate_or_settings) sizes the stacker racks (site pitch = stack thickness, site height = full plate height).
  • BenchCelLabwareSettings.from_xml_file(...) can parse user-supplied VWorks XML for comparison, but no XML profiles are bundled.
  • BenchCelLabwareSettings.to_device_payload() encodes the labware geometry as the 77-byte 0x7d payload, and backend.set_labware(...) pushes it to the device (followed by the 0x9f commit, exactly as VWorks does). The layout was decoded from VWorks captures of labware changes and validated byte-for-byte (e.g. Greiner 781101: StackingThickness=12.76 at offset 0, full plate height 14.4 at offset 37). Invalid geometry is rejected by the device with a 0x02 error (e.g. "The labware gripper positions are too close"), surfaced as a BenchCelDeviceError.

The calculation was validated against four real VWorks XML/dimension pairs (Greiner 655101/781101/782076, Agilent Seahorse) plus a Corning Costar 3961 deep-well block: the safety-critical RobotGripperOffset / pickup-distance-from-top calculates exactly in every case; StackerGripperOffset and nesting overlap genuinely vary by labware family and so remain overridable.

Mock server — pylabrobot.storage.agilent.BenchCelMockServer

A localhost asyncio TCP server speaking the same framed protocol, usable two ways:

async with BenchCelMockServer() as srv:
    backend = BenchCel4RBackend(host=srv.host, port=srv.port)
    await backend.setup()
    await backend.home()
    await backend.move_to_stacker(3)
python -m pylabrobot.storage.agilent.benchcel_mock_server --port 7612
  • Tracks a simple arm pose, validates payloads, returns 0x02 device-error frames for out-of-bounds moves and too-close labware gripper positions, and reproduces the split-ACK and per-stacker load/unload ACK shapes seen on the wire.

Folder layout

pylabrobot/storage/agilent/
├── __init__.py
├── benchcel.py               (BenchCel4R factory)
├── benchcel_backend.py       (BenchCel4RBackend + protocol)
├── benchcel_labware.py       (calculation-based labware integration)
├── benchcel_mock_server.py
├── benchcel_tests.py
└── benchcel_mock_server_tests.py

Tests

File Count What
benchcel_tests.py 26 Stub-based: frame parse/split, status/sensor/axis-bounds parsers, labware calculation + 77-byte 0x7d payload (byte-for-byte vs capture) + validation, factory wiring, serialization, teachpoint-required transfer errors, plate→stacker mapping, and per-command wire-byte assertions (incl. the capture-confirmed downstack/upstack/load/unload primitives) via a fake socket.
benchcel_mock_server_tests.py 7 Real-TCP integration against the mock: motion + arm status, all-stacker sensor sweep, out-of-bounds device error, teachpoint save→move, stacker-clamp diagnostics, set_labware, and labware rejection.

All 33 tests pass; pylabrobot.storage.incubator_tests still passes.

Docs

  • New user guide: docs/user_guide/01_material-handling/storage/agilent_benchcel.rst (connection, manual safety notes from G5400-90003A, labware calculation, status/sensor reads, stacker/teachpoint moves with the VWorks-confirmed opcode mapping, the cosmetic-loading_tray caveat, the mock server, and the dangerously_ rationale).
  • docs/api/pylabrobot.storage.rst added and wired into docs/api/pylabrobot.rst; BenchCel added to the storage user-guide toctree and comparison tables.

Resolved since first draft: plate stacking-z-height (stack pitch) is now mapped

The earlier headline open item — the BenchCel stack pitch / plate-to-plate "stacking-z-height" (VWorks StackingThickness) not being mapped to the wire — is resolved. Captures of VWorks labware changes show it is field 0 (LE float32) of the 77-byte 0x7d labware-settings payload, pushed to the device followed by a 0x9f commit. The backend now both calculates it from PLR plate dimensions and can push it via set_labware. Full plate height sits at offset 37 of the same payload.

Still open / caveats

  • Lidded & sealed plate configurations are not tested. BenchCelLabwareSettings carries lidded_plate_stacking_thickness / sealed_plate_stacking_thickness fields, but those 0x7d sub-fields were always zero in the available captures (standard flat microplates only), so the lidded/sealed paths are unverified against hardware. Captures of a lidded or sealed plate being configured in VWorks would let us map and test them.
  • PLR has no native microplate stack-pitch / nesting-overlap field (only Lid.nesting_z_height and NestedTipRack.stacking_z_height exist, neither of which is a microplate stack pitch). The PR derives stack pitch as plate.size_z − nesting_overlap with a default overlap of 1.5 mm (matched the Greiner/Seahorse examples within ~0.2 mm, but the Corning Costar 3961 deep-well nests ~2.26 mm). It is overridable via nesting_overlap=, and PLR-side stack-height bookkeeping should be treated as advisory since the firmware tracks its own stack state.
  • The 0x71 command (observed once before a Load) and the stacker shelf command (support/level plates during downstack/upstack) are intentionally not exposed because their captured commands aren't yet confidently identified. 0x85 (current position) is preserved as raw bytes since only the 0x87 layout is decoded.

Safety note

This integration was developed against a physical BenchCel during reverse engineering, but all wire-level behaviour in the test suite runs against the mock server. The one stack-dropping operation (dangerously_open_stacker_grippers) is prefixed accordingly. Before any real motion: keep the robot/stacker area clear, ensure E-stop/power-off is reachable, confirm compressed air is on, and disconnect VWorks/other control clients (the BenchCel effectively allows one control client at a time). Opening stacker clamps or retracting shelves with an unsupported stack can drop plates.

Contributor-guide compliance

Check Status
ruff format --check (new files)
ruff check + import order (--select I)
pre-commit (ruff-format, ruff-check, typos)
mypy --check-untyped-defs (new modules)
BenchCel + incubator unit tests ✅ 33 + existing pass
Google-style docstrings + type hints
OS-agnostic (pure stdlib asyncio)

Backwards compatibility

No breaking changes; this is purely additive under pylabrobot.storage.agilent with re-exports from pylabrobot.storage.

@c-reiter c-reiter force-pushed the add-agilent-benchcel-4r branch 3 times, most recently from c8c4eeb to b07c7dc Compare June 24, 2026 00:02
@c-reiter c-reiter force-pushed the add-agilent-benchcel-4r branch 2 times, most recently from e553767 to 6e203e6 Compare June 24, 2026 01:47
Follow-up to PyLabRobot#1110. When stacking bare plates in the z direction, a plate
placed directly on another bare plate now nests in by `size_z -
stacking_z_height` instead of resting at the lower plate's full height, so
a stack of N identical plates is `size_z + (N - 1) * stacking_z_height`
tall (both `get_size_z()` and child placement). Gated on the value being
set and skipped when the lower plate wears a lid, so existing behaviour is
unchanged for plates without a `stacking_z_height`. This parallels the
lid-nesting `ResourceStack` already did.

Also includes the documentation for `stacking_z_height` (the Plate
resource page, the ResourceStack notebook, and the example definition in
the contributor guide) that was part of the original PyLabRobot#1110 work.

These changes were force-pushed to the PyLabRobot#1110 branch shortly after it was
already merged, so they never made it into main; this PR brings them in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@c-reiter c-reiter force-pushed the add-agilent-benchcel-4r branch 2 times, most recently from 1c0de90 to 1094a06 Compare June 24, 2026 02:14
Adds an integration for the Agilent BenchCel 4R microplate handler, modeled
as a PLR storage machine, plus an in-process mock TCP server that emulates the
device's binary wire protocol for hardware-free development.

- BenchCel4RBackend speaks the reverse-engineered framed binary protocol over
  TCP/7612 (reverse-engineered from VWorks captures + live tests).
- BenchCel4R(...) factory returning a plain Incubator (it takes a name like all
  PLR device factories, since the returned Resource needs a unique name for the
  resource tree/serialization/lookups).
- Regular motion/transfer APIs (home, move, pick/place, downstack/upstack, jog,
  gripper moves, load/unload, save_teachpoint, fetch/take_in_plate, ...) are
  unprefixed. Only opening the stacker clamps is exposed as
  dangerously_open_stacker_grippers, because that specific diagnostic can
  release/drop a plate stack.
- Calculation-based labware integration: derive BenchCel geometry
  (StackingThickness, RobotGripperOffset, StackerGripperOffset, SensorOffset)
  from PLR Plate dimensions instead of bundling per-catalog XML profiles.
- set_labware(...) pushes labware geometry to the device using the decoded
  0x7d settings frame + 0x9f commit handshake. The 0x7d payload layout was
  reverse-engineered from VWorks packet captures and reproduces the captured
  frames byte-for-byte for standard flat microplates (StackingThickness,
  robot/stacker/sensor gripper offsets, full plate height at offset 37, notch
  flags, thresholds, and PlatePresenceThreshold at offset 75). Invalid geometry
  is rejected by the device with a 0x02 "labware gripper positions are too
  close" error, which is surfaced as BenchCelDeviceError.
- BenchCelMockServer + tests (stub-based and real-TCP integration), including
  the 0x7d/0x9f settings handshake and its rejection path.
- User guide + API docs.

No files are written on the host running PLR: the device cannot read teachpoints
back, but PLR does not persist any local teachpoint registry/files. Keep the
numeric teachpoints you write in your own protocol/config if you need them.

Known limitations / not yet covered:

- Lidded and sealed plate configurations are NOT fully supported. In every
  captured VWorks 0x7d frame for standard flat microplates, the lidded/sealed
  sub-fields (lidded stacking thickness / plate thickness / resting height /
  gripper offset / gripper position / departure height, and sealed stacking
  thickness / plate thickness) plus ErrorCorrectionOffset were always zero, so
  their exact wire offsets could not be confirmed. They are therefore encoded
  as zero by to_device_payload() and are not decoded by from_device_payload().
- As a result, set_labware() should not be used for lidded or sealed labware
  yet, and there are no tests covering lidded/sealed geometry round-trips.
  Pinning these down needs captures where a lidded plate (e.g. a tip box with
  lid) and the sealed Seahorse plate are applied in VWorks with non-zero
  lidded/sealed values.
- The robot/stacker/sensor gripper offsets calculated from PLR Plate dimensions
  are heuristics validated against standard SBS microplates; they are not part
  of the PLR labware definition and should be verified/overridden for unusual
  labware before pushing to hardware.
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.

1 participant