feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed heads) - #1184
feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed heads)#1184vcjdeboer wants to merge 37 commits into
Conversation
6af085c to
1ae9dc6
Compare
|
Downstream report: bridged this driver into an agent-facing protocol (simulation only) I maintain the labwire bridge that wraps PyLabRobot for AI-agent control (discussed in the earlier thread about cancellation on the Flex). Since the plain-class redesign will eventually reach us, I ran an experiment against this PR to see how a protocol adapter fares in the new architecture. Everything below is observation from that exercise, in case any of it is useful mid-redesign. Nothing here needs action on my account. Setup: pinned this PR's head commit (6ee378e), drove OpentronsFlex through our protocol layer, and exercised it against a simulation of the robot-server command layer (mock HTTP for /health, /runs, /runs/{id}/commands, /instruments), which I understood to be how the PR itself was validated. No hardware involved on my side either. What ported cleanly:
Things I hit that may be worth knowing:
The driver held up well under a fairly adversarial consumer. Happy to share the simulation harness or the full compatibility notes if either is ever useful. |
|
thank you @vcjdeboer ! could you please rebase this onto main? |
6ee378e to
06971f2
Compare
06971f2 to
5166ba4
Compare
…dressed heads) Add the Opentrons Flex as a plain-class device (post-capability architecture). - OpentronsRobot(abc.ABC): shared base owning the robot-server HTTP transport (behind a swappable OpentronsTransport Protocol, with an httpx transport and an offline recording transport for dry runs), the run/command lifecycle, and instrument discovery. - OpentronsFlex(OpentronsRobot): the device. setup() discovers the mounted pipette(s) and composes a head sub-object per mount (flex.left / flex.right), or flex.head96 for the 96-channel head. stop() drops any mounted tips to the trash, homes the gantry, then cancels the run and disconnects. - FlexHead1 / FlexHead8 / FlexHead96: mount-addressed fixed heads. Each op sends ONE robot-server command anchored at the reference well; the hardware fans it out to the head's N nozzles. Tip/volume state commits to the resource tree (TipSpot.tracker / Well.tracker), only for actuated channels (None-skip) and only via a transactional stage -> wire -> verify -> commit/rollback. The Flex hardware tip-presence sensor is the authority for tip presence: pickups are verified against it (rolling back on a missed pickup) and get_mounted_tips() is reconciled against it. Aspirate/dispense default to 1 mm above the well bottom and auto-issue prepareToAspirate before the first aspirate after a pickup. - Labware is name-based: the robot owns the authoritative geometry, resolved from the Opentrons load name (ot_load_name); PLR resources carry a nominal SBS grid for tracking/addressing only. FlexDeck models slots as ResourceHolders; name-based tip-rack and plate factories. - Docs: a Head8 hello-world notebook and API reference, wired into the docs toctrees. FlexHead8 is verified on real Opentrons Flex hardware (robot-server API 8.8: setup, homing, and column tip pickup against the tip-presence sensor). FlexHead1 and FlexHead96 are implemented but not yet hardware-verified and emit a one-time warning on first use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5166ba4 to
5f3869c
Compare
|
@Silousr thanks for the test. The PR has been reworked since the commit you pinned. It is now a mount-addressed head model (flex.left / flex.right / flex.head96, with FlexHead1 / FlexHead8 / FlexHead96), so two of your points are addressed:
The import artifact was a v1b1-base issue and clears on the current main rebase. FlexHead8 is verified on a real Flex; Head1/Head96 are coded but not yet hardware-tested. Also, the bigger reason was the abstraction: the Flex only has fixed heads (1/8/96-channel across two gantry mounts), so a flat single-channel class with a use_channels list was modelling independently-addressable channels the machine doesn't have (I think use_channels is a Hamilton STAR concept). The head model matches the real hardware, and it lets the robot stay the authority for the things it owns anyway (labware geometry via load names, tip presence via the tipDetected sensor). So this build doesn't include a reach-geometry model like the OT2RobotGeometry that @BioCam added for the OT-2 (in resources/opentrons/, though nothing wires it into a device yet I think). Worth deciding whether the Flex should use the same approach. |
- flex.gripper (FlexGripper): atomic moveLabware usingGripper with pre-wire validation and deck reparent on wire success; ungrip recovery; discovery from GET /instruments (extension mount) - OpentronsFlex.labware_moved_off_deck: offDeck manualMoveWithoutPause release so externally removed labware frees its slot server-side - blow_out / touch_tip / liquid_probe / try_liquid_probe on FlexHead1/8/96 where each physically applies; z_position absent-not-null quirk pinned - ChatterboxTransport: gripper instrument + liquid_probe_z sim knobs - 32 new offline tests (12 gripper, 20 fine pipetting); suite 56 -> 88 Two capabilities share this commit because both touch transport.py's sim knobs; split per-capability when the upstream PR shape is agreed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds and gripper - Head1 aspirate/dispense accept Union[Well, Container]; Head8 gains aspirate_container/dispense_container (column is meaningless for one cavity); Head96 accepts Union[Plate, Container] with the 12x8 grid centered in the cavity (back-left anchor nozzle at (-49.5, +31.5)) - span-fit guards reject cavities smaller than the nozzle footprint; container trackers stage ONE cumulative volume (volume x mounted channels) so a mid-staging failure cannot orphan pending tracker ops - position()/move_to() jog on all heads (savePosition/moveToCoordinates, traversal default 120mm); gripper move_to via robot/moveTo mount=extension, grip(force 2-30N)/open_jaw; robot/* version gate (>= 8.2.0, dev builds exempt); the Flex gripper cannot rotate labware - ChatterboxTransport: saved_position knob + savePosition result, and the labware_definitions upload endpoint (used by the next commit) - 36 new offline tests; suite 88 -> 124 at this commit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…abware
Labware without an official Opentrons definition (Corning/Falcon black
plates, Hamilton troughs) previously raised at first use. Now
_ensure_labware_loaded falls back to building a robot-server definition
from PLR geometry (plate/tip-rack/container/movable-stub builders in
labware_definitions.py), uploading it via
POST /runs/{run_id}/labware_definitions, and loading by the returned
definitionUri (namespace pylabrobot, version 1). One upload per
definition per run; official-name labware is byte-identical to before.
cornerOffsetFromSlot.y = 86 - size_y converts PLR front-left to
Opentrons back-left anchoring; wells carry real depth/volume so
touchTip/liquidProbe get true geometry; gripHeightFromLabwareBottom
rides along when grip info is available.
18 new offline tests; suite 124 -> 142.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blockers:
- B1: liquid probing starts at the well TOP (+2 mm, the engine's own
LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP) instead of the well floor; a
real-hardware no-liquid probe FAILS the command with the defined
"liquidNotFound" error, so liquid_probe now catches the wire failure
(new OpentronsCommandError carries the error payload) and translates it
to LiquidNotFoundError. ChatterboxTransport grows
simulate_liquid_probe_not_found to model the failure shape.
- B2: single-cavity container definitions carry the
centerMultichannelOnWells quirk (matching every shipped Opentrons
1-well reservoir) and the manual Head8/Head96 centering offsets are
gone -- the engine centers the nozzle array itself; stacking both
drove the array ~27 mm past the cavity wall. (The old backend was safe
with the same offsets because it moved via moveToCoordinates, which
sets no destination critical point.)
- B3: cornerOffsetFromSlot is {0,0,0} in every builder: schema-2
definitions anchor at the slot's front-left-bottom exactly like PLR,
so the 86 - size_y y-flip pushed all custom labware toward the back.
Majors:
- M1: touch_tip is top-relative (default 1 mm below the rim, caller
offset replaces it), matching PAPI v_offset semantics.
- M2: every Head8 column op validates the column against the labware's
real grid (bounds + 8-row layout, anchor derived from the resource)
BEFORE any wire command; kills negative-index aliasing, the 384-well
IndexError, and rejected ops that had already shipped commands.
- M3: column-4 staging slots (A4-D4) ride {"addressableAreaName"} on
moveLabware AND loadLabware -- the server's DeckSlotName has no A4-D4.
- M4: non-Plate/TipRack/Container resources route to the movable stub
definition, and grip_distance_from_top threads from
FlexGripper.move_labware through _ensure_labware_loaded into every
builder (honored on first load only).
- M5: plate builders keep PLR well geometry (rectangular cross-sections,
u/v/flat bottoms); tip-rack wells stay circular on purpose.
- M6: _loaded_labware and _defined_labware clear on every new run and
the definition cache is evicted when labware moves off deck.
Minors:
- N1: version gate pads to 3 parts ("8.2" passes), exempts dev builds
and the chatterbox "dry-run", and rejects unparseable versions.
- N2: custom load names append a sha1[:6] digest of the raw name so
colliding sanitizations never share a definitionUri.
- N3: FlexHead1 container ops require a mounted tip pre-wire; coverage
gaps filled (Head1 container rollback/no-tip, Head8 probe no-tip,
dispense_container rollback/no-tip, Head96 y-axis span fit).
- N4: the span-fit guard includes the caller offset: the shifted array
must fit the cavity.
- N5: untested-hardware warnings are op-scoped: FlexHead8's verified
column-pickup lineage never warns; all other head and gripper ops
(blow_out/position/move_to included) warn once per instance.
- N6: __init__ no longer exports the driver-internal definition
builders.
- N7: parameters.format derives from the grid (96Standard/384Standard/
irregular) for plates and tip racks.
- N8: blow-out tests pin _DEFAULT_BLOW_OUT_FLOW_RATE; deleted two tests
duplicating flex_tests.py coverage and one mock-echo test.
Suite: 142 -> 178 tests; the 56 pre-existing flex_tests/transport_tests
are byte-identical.
…ub-labware refusal on Flex Six review findings against the Flex capability stack. A caller offset now SHIFTS the head from the default liquid position instead of replacing it. A Coordinate carries z=0 when the caller only meant to nudge x/y, so `aspirate(..., offset=Coordinate(x=1))` used to drop the 1 mm bottom clearance and put the tip on the well floor. liquid_height stays the base the offset rides on. touch_tip deliberately keeps replace semantics: its z is the touch height itself, mirroring the Opentrons Python API's absolute v_offset, and dropping that default moves the tip up toward the rim rather than down into the labware. Both rules are stated in the docstrings. The container span guard read the container's own x/y while the uploaded definition carried the deck-frame (rotation-aware) footprint, so a rotated cavity was guarded on the wrong axis in both directions: an 8-nozzle row passed a cavity 23 mm too shallow, and a cavity deep enough was refused. Guard and builder now share one helper, container_cavity_footprint, and the error prints the rectangle the robot actually sees. The unbuildable-labware refusal is back. _build_labware_definition had been widened to return the non-pipettable movable stub for anything it could not build, which let a pipetting op on a tube rack upload a zero-depth fake well and ship an aspirate 1 mm above the deck. Gripper-intent callers now opt in with allow_stub; every other caller raises before any wire command, including on a load-cache hit after the gripper already loaded the resource. 8-channel column ops no longer reject every 384-well plate. The nozzles hold their 9 mm pitch, so a 16-row plate has two interleaved sets of 8 per physical column and takes column 0-47; a row count that is not a multiple of 8 is still rejected pre-wire. grip_distance_from_top is logged when it cannot be honored (catalogue definitions, already-loaded labware, already-uploaded definitions) instead of being dropped silently, and the gripper docstring says which labware it applies to. The too-small-container error no longer blames an offset the caller never passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Uploaded labware definitions described the wrong physical heights, so the Flex aimed liquid ops below the surfaces it was told about. Wells now carry their cavity floor, not the labware's outer base. An Opentrons well "z" is the inside of the well; a PLR well or container is anchored at its outer bottom, so both differ by the wall thickness. A trough aspirate at the default 1 mm clearance was commanded 0.58 mm into hamilton_1_trough_60mL_Vb's floor, and every plate aspirate landed ~0.5 mm low (uploading 3.03 for cor_96_wellplate_360uL_Fb where Opentrons' own corning_96_wellplate_360ul_flat says 3.55). Labware that never declared a material_z_thickness is refused by name rather than defaulted to zero, which is the same crash written differently. Tip-rack wells now describe the seated tip. A TipSpot has no height of its own, so every rack uploaded depth 0 and the pickup target collapsed onto the rack's base, ~59 mm into a 200 uL filter rack; racks whose tips hang below their own base (the Hamilton NTR family) uploaded a negative z, which robot-server rejects with a 422 per well. Depth is the prototype tip's length and a below-base rack is refused with a message naming the rack. Rebuilding a shipped rack now reproduces its own file (5.39 / 59.3 / 59.3). Rotated plates and tip racks are refused instead of uploaded. Their footprint was rotation-aware while their well coordinates were not, so a plate rotated 180 in a slot reported wells ~99 mm from where PLR put them. Schema 2 cannot express a rotation at all, so a transform cannot fix it. Other behavior in the same pass: - Every liquid op on every head requires a mounted tip. Only the container branches checked, and a tipless dispense moves to the well BEFORE the engine rejects it, driving the bare nozzle a tip length too low. - Single-tip pickup no longer derives the nozzle from the well's row: an 8-channel Flex anchors on A1 or H1 only, and rows B-G built a primaryNozzle the robot-server 422s. Rows A/H keep their obvious anchor; any other well takes an explicit primary_nozzle, and the tracked channel follows the nozzle rather than the well. - No nozzle reconfiguration is sent while a tip is mounted, "ALL" included. The engine refuses those outright. - Catalogue labware loads the earliest definition revision that states a gripper grip height instead of always version 1, which states none for much of the catalogue and leaves the robot gripping at mid-height. Those revisions have shipped since API 2.14 and move no well; a resource can pin its own with ot_version. - The robot/* version gate stopped exempting any version containing "dev", which let a real 8.1.0.dev5 robot past a gate meant for untagged builds. - Container definitions carry the trough's real bottom shape, lower-cased the way the robot-server's schema requires. - container_cavity_footprint is renamed container_footprint and says plainly that it is the outer box, an upper bound on the cavity. - The deck-slot wire encoding and the untested-hardware notice move to a shared flex_wire module, so the device module no longer imports private names from the optional gripper module. - touch_tip's public docstrings state that its offset replaces the touch height rather than shifting it, the opposite of every other offset. - The gripper, heads and OpentronsCommandError are in the API docs.
…bot can reach Two defects in the 8-channel single-nozzle path, both of which reached the wire or stranded the robot. A cherry-picked tip could never be dropped. Every trash drop reset the nozzle layout first, and the robot refuses any reconfiguration while a tip is on, so drop_tips/discard_tips raised before sending anything and stop() disconnected with the tip still on the pipette. The reset now runs after the drop, which addresses no channel and needs no layout. Returning a single tip to a rack column is refused by name instead of falling into the same guard. The A1 anchor was hard-coded, which puts rows F/G/H of any front-row slot outside the robot's reach: a SINGLE layout still carries the whole pipette, whose body hangs 95mm forward of the mount. The engine's own extents check guards Python-API protocols only and never sees the run commands this driver posts, so nothing downstream would have caught it. The anchor is now chosen from the well's position, an explicit but unreachable one is refused naming the one that works, and single-nozzle aspirate/dispense check the same band. Choosing by reach also gives middle-row wells a principled anchor, so they no longer need the caller to name one. Numbers come from the shipped Opentrons data: ot3.json padding offsets and extents, and the eight-channel pipette nozzle map and bounding box.
The Flex driver's only real transport is an httpx.AsyncClient, so `pip install pylabrobot[opentrons]` installed a Flex that raised the moment you constructed a transport.
dispense carries the comment twice: once above the block it describes, and once ~20 lines earlier with nothing under it. The stray copy reads as though offsets were re-based on the resource centre at that point, which is where the surrounding code does no such thing.
Both robots only offered their lifecycle welded into setup()/stop(), so a
caller who wanted one step got all of them. Most costly on the Opentrons:
the robot refuses its own touchscreen while a run is current, and the only
way to end that run was stop(), which homes on the way out. Homing an arm
over a deck an operator is reaching into is not acceptable, so in practice
the robot got power-cycled instead.
Each step is now callable on its own, and setup()/stop() keep their exact
previous behavior by composing them:
Opentrons
setup() = connect + create_run + home + initialize
stop() = home + cancel_run + disconnect
PreciseFlex
setup(skip_home) = connect + initialize + home
stop() = disconnect
connect opens the link and nothing else. create_run starts the control
session, which is the step that takes an Opentrons robot from its operator.
initialize gets the device ready to command without moving it: discovery
and head composition on the Flex, high power and attach on the arm. home
is the only step that moves. cancel_run hands an Opentrons robot back to
its touchscreen while staying connected. disconnect drops the link, ending
any open run first so the robot is never left locked out with nothing to
release it.
initialize no longer homes. On the Flex that meant taking home() out of
_model_setup, so asking what is mounted no longer moves the robot; setup()
homes explicitly and the sequence it runs is unchanged.
setup/stop stay: they are PyLabRobot's convention (139 setup and 163 stop
definitions across the tree, including the non-legacy packages) and
MachineBackend declares both abstract.
Every Opentrons liquid-handling command carries a required flowRate, and the driver filled it from one pair of constants for every pipette and tip. Those defaults span 6 to 716 uL/s across the Flex range, so the constants were wrong for nearly every pipette that is not a p50 eight-channel: a p1000 eight-channel on 1000 uL tips ran 20x under its own default, and a p200 96-head on 20 uL tips over 5x above it. Read them instead from opentrons-shared-data, the package robot-server itself loads, keyed on the pipette model /instruments already reports and the tip actually mounted. No pipette numbers are vendored, so nothing drifts when Opentrons revises them. Two traps closed on the way: - Opentrons' own model parser resolves an empty model string to a p1000 single-channel rather than raising, which would blow a p50 out at up to 716 uL/s. An empty model is now refused. - ChatterboxTransport reported a pipette's NAME where /instruments serves a versioned MODEL, so simulated runs would have failed the lookup outright. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he trash is Two places the driver guessed instead of reading what it had. Labware resolution fell back to substring-matching the resource's INSTANCE name against a hand-kept tip-rack table, then to any name starting with "opentrons_". A resource's name is a user-chosen label, so a plate named after a tip rack resolved to a tip rack, while a correctly-modelled resource under a project naming scheme resolved to nothing. It now reads ot_load_name, else the model that PLR's own Opentrons factories already set to the load name. A declared ot_load_name is now checked against Opentrons' shipped catalogue and a miss raises ValueError rather than OpentronsError, because the caller treats OpentronsError as "build a definition from geometry instead" -- so a typo used to load silently at geometry the operator never asked for. Trash drops hardcoded movableTrashA3 while every caller was already handing over the Trash it wanted. A Flex takes a movable trash in any of eight slots, so the addressable area now comes from the slot the trash is actually in, and a trash outside those eight is refused by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`done()` delegated the flag to `reset()`, which sets it True, so finishing a validation left capture-or-validation active. Every io refuses construction while that flag is set, so nothing could be built afterwards and a second validation in the same process was impossible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills the empty `io/http.py` so a device driven over HTTP can be pinned the way a serial one already is: record a known-good run with `start_capture()`, replay it through `HTTPValidator` with no device present. Failed exchanges are recorded before they raise, so a device's refusals replay as the same error, which is the half that error handling is written against. `HTTP` is not an `IOBase`: that base's `write`/`read` model a byte stream whose halves are separately meaningful, while a request and its response are one exchange, so the pair is the unit recorded and replayed. `testing/http_server.py` answers an io's calls from a handler, so any HTTP driver's tests can run without a socket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…end_command` `HttpxTransport` now holds an `HTTP` io rather than a bare `httpx` client, so every robot-server exchange lands in the capture log and a recorded run replays through the new `ReplayTransport` with nothing on the network. Where `ChatterboxTransport` returns responses written by hand, that one returns what a robot actually gave, in order, and raises the same refusal where one failed. `assert_fully_replayed()` fails a replay that stopped short, which is what catches a dropped command. The transport gains `setup()` because an io cannot be built while a capture is active, so it has to exist before recording starts. `send_command()` reaches the rest of the robot: the module families, calibration, and whatever a later software release adds. It validates nothing and updates no resource-tree state, so a typed method is preferred where one exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…commands Takes the driver from 22 robot commands to 42. Motion by name rather than by measurement: `move_to_well` sends `moveToWell`, so the robot resolves the position and refuses a move it cannot make, the same way it checks an aspirate. `move_to` sends raw coordinates that nothing on either side bounds-checks, so it is now for free-space jogs only. With `move_relative`, `move_to_addressable_area`, `move_axes_to`, `move_axes_relative` and `retract_axis` beside it. `sync_tips_to_robot` pushes the resource tree's tip layout onto the robot, which is the only way to reconcile a rack changed outside a run. Well volumes have no equivalent: `loadLiquid` refuses any liquid the run did not define, and a run created without a protocol defines none. Probing sets the robot's liquid height instead. In-place pipetting, tip-presence reads, and the unsafe recovery pair for when a normal drop or blow-out cannot run. The `robot/*` family takes snake_case parameters unlike every other command here, and its version gate moves to `flex_wire` so the device module no longer reaches into the optional gripper module for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a Flex, GET /instruments re-caches the attached pipettes, and that re-cache clears the run's record of the attached tip. Reading the sensor that way between a pickup and the next pipetting command left the robot refusing that command with "cannot perform PREPARE_ASPIRATE without a tip attached", while the same read reported tipDetected: True. _verify_tips_seated and _confirm_tips_cleared ran on every pickup and drop, so the whole fine-pipetting chain was unreachable. Both go through has_tip_on_hardware, which now asks with the getTipPresence run command: the same one bit per pipette, without the side effect. Reproduced and fixed against the Opentrons robot-server simulator. ChatterboxTransport answers the tip-presence commands so the simulated failed-pickup and stuck-tip modes still drive the same paths, and it now refuses a verifyTipPresence mismatch the way the robot does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_ensure_labware_loaded proposed a fresh uuid4 for every load and then preferred whatever id the robot answered with, so the proposed one was never used for anything. It did make the request body differ run to run, which is enough to make a recorded capture unreplayable: validation matches on the request, and this was the driver's only nondeterministic field. Dropping it makes the load request a function of the resource and the slot. A load that somehow reports no id now raises instead of silently addressing later commands by an id the robot never knew. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Driving every driver op against the Opentrons robot-server simulator turned up two things the unit tests could not, because ChatterboxTransport accepted whatever the driver sent. sync_tips_to_robot sent tipWellState "tipPresent"/"tipAbsent". The engine's TipRackWellState is "clean"/"used"/"empty", so the command 422'd every time and the method had never worked once. PyLabRobot only records whether a tip is there, so an occupied spot maps to "clean". The tests that covered this asserted the wrong values and passed anyway, so the fake now refuses a param value the real robot-server rejects, for setTipState, verifyTipPresence and moveLabware alike. A dispense or blow-out leaves the plunger past its dispense bottom, and the robot then refuses the next aspirate until a prepareToAspirate resets it. The driver only tracked this after a pickup and a blow-out, never after a dispense, so aspirate -> dispense -> aspirate failed on the second aspirate. That is the ordinary transfer loop. The rule now lives in one place, at the command chokepoint, rather than in six scattered assignments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…repeats The two engine-contract blockers (setTipState values, priming after a dispense) were already fixed; this covers the rest. The documented way to record a capture did not work. A robot built its transport inside connect(), and every pylabrobot io refuses construction while a capture is active, so "construct, start_capture, setup" died unless the caller had passed a transport in themselves. The transport is now built in __init__ and kept across a disconnect, which is also what lets a reconnect reopen it. CaptureReader.done() now ends validation even when its assertion fails, so one short replay no longer strands every io built after it. configureForVolume also clears the robot's ready-to-aspirate flag, so it joins the commands that force a fresh prepare. The set stays deliberately wider than the robot's own rule, which primes on pickUpTip and only unprimes on a dispense that pushes out or empties the tip; a redundant prepare is accepted, and narrowing it is a physical change to make against a real robot. Also: a command's poll timeout now scales with its own plunger travel, so a large aspirate at a slow flow rate no longer times out and rolls the trackers back while the robot keeps pipetting; opentrons-shared-data is imported where it is used rather than at module scope, so importing pylabrobot.opentrons without the extra no longer hard-fails; re-running setup() no longer stacks a second set of heads on dead pipette ids; single-nozzle pickups now check the neighbouring slot for the 7 idle nozzles, which nothing called before; ReplayTransport is exported; and PreciseFlex's disconnect docstring names initialize, not connect, as what raises high power. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… line The plain-class rewrite left three things behind that consumers of the Flex deck and the OT-2 backend depend on. FlexDeck regains the reads its sibling OTDeck already has: `slots` and `slot_locations` name what is in each slot and where each slot is, and `get_slot_holder` hands out the holder a gripper move targets. With them the deck can also route an `assign_child_resource` at a bare coordinate into the matching slot, which is what a gripper move_plate does when the liquid handler re-parents the moved labware to the deck; without it that call lands nowhere and the robot's model and the resource tree drift apart. `get_trash_area96` says the 96 head discards into the same movable bin as the others. The OT-2 backend regains `move_channel_to` and `get_channel_position`. Moving one axis at a time was already possible, but each of those descends separately, so a three-axis move could clip labware between steps; `move_channel_to` lifts to the traversal height and travels once. Restores the FlexDeck test module with it.
The robot addresses a tip rack's wells by the same names a plate's use, so hovering over a tip spot is a valid move. It is also the cheapest safety check there is: look at where a pickup would descend, 20 mm up, before committing the nozzle to it. A tip spot is neither a Well nor a Container, so it used to fall through to the container branch and try to load the spot itself as labware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…old run Two changes that let a caller reach an Opentrons without describing its deck first. attach_deck swaps the deck on a live OpentronsFlex. Without it the only way to change decks was to build a new robot, which throws away the link and the run. It clears the labware caches, whose ids describe the deck being replaced. create_run now cancels a run this object already holds before opening the next. run_id is the only handle we have on a run, so overwriting it left the old one current on the robot with nothing able to release it: touchscreen locked, power cycle the only way out. That is the failure the lifecycle work exists to prevent, so create_run must not be able to cause it.
A head already knows its mount, channel count and pipette model, but not the volume that pipette holds, so anything describing the head had to re-read /instruments for one number -- a round-trip in the path of every state poll.
Ten liquid ops each built the same params dict, resolved the same flow-rate default and ran the same well-location branch. They now call _pipette(). The per-well tracker staging and the well-or-container addressing were likewise copied per head; both are single helpers now. Also trims docstrings back toward the length the surrounding file uses, and drops the three copies of the same touch_tip offset explanation. No behaviour change: same commands, same params, same ordering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FlexHead1 was run on a p50 single channel: motion, tip pickup and drop, liquid probe, aspirate/dispense and volume mode. Its docstring said the opposite, because the only bench Flex available before carried an 8-channel pipette. FlexHead8's column-pickup entry is unchanged; that claim is not ours. The two tests that asserted FlexHead1 warns now cover a head that genuinely has not been run, and a new one pins that a verified op stays quiet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The driver sent prepareToAspirate as its own command before every well-addressed aspirate whose plunger it believed unprimed. Priming moves the plunger up, and after a dispense the tip is still in the well it dispensed into, since a well-addressed dispense does not retract. So an ordinary transfer loop primed submerged and drew an unmeasured slug of the destination well into the tip: about 3.9 uL on a p50, 11.8 in low-volume mode, 79.5 on a p1000, off the shipped plunger positions and shaft uL/mm. None of it was needed. A well-addressed aspirate primes itself on the robot, moving to the well TOP, priming in open air and then descending. Sending our own prepare first set the ready flag and skipped exactly that. So the driver now sends none, and the flag it kept to decide when to send one is gone with it. Two of that flag's rules were backwards anyway: a tip pickup PRIMES the plunger rather than un-priming it, and only a dispense that empties the tip un-primes, because only that one gets a push-out. The in-place draws and the liquid probe name no well, so the robot has no safe height to prime at and refuses instead of guessing. Those now pass the robot's own refusal back, adding the remedy its message leaves out: lift the tip clear and prepare_to_aspirate(), which is public for that. Reading the answer off the wire beats keeping a copy of it, which cannot stay honest through a raw send_command or a fresh process. ChatterboxTransport learns the engine's ready-to-aspirate rule, so a draw the real robot refuses fails against the fake too. This shipped because the fake accepted whatever the driver sent, the same way the tipWellState bug did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ongs Bench steps 31 and 32 ran aspirate_in_place, air_gap_in_place and dispense_in_place on a real p50, so they stop logging the untested-hardware notice. Step 32 also confirmed the priming rule end to end: with the plunger past its dispense bottom, a well-addressed aspirate lifted to the well top, primed in open air and descended by itself, while the in-place one refused. The in-place docstrings said only that the caller owns the position, which reads as permission to pipette from wherever the head happens to be. It is not: dispensing from above the liquid splashes and strands volume, drawing from above it takes air. They now say what a good position is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three files under pylabrobot/legacy/ had nothing to do with the Flex plain-class driver and were only along for the ride: the PreciseFlex lifecycle split, the OT-2 coordinated channel move, and a duplicate comment deletion in the legacy LiquidHandler. They go to PyLabRobot directly instead, where they belong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PyLabRobot should not depend on Opentrons' data package. It also pins numpy~=1.26.4, which fights whatever numpy the rest of a user's environment wants, and it was only ever read for two static tables. Both tables now live in the tree, the way the Hamilton backends already carry their liquid classes: the Flex flow-rate defaults in pipette_defaults (31 pipette models over 7 distinct rate tables) and the 150 catalogue load names in catalogue. Neither is derivable from the robot -- GET /instruments reports channels and volume range only, no endpoint serves a labware catalogue, and the protocol engine rejects a command that omits flowRate rather than filling one in, so the numbers have to be on the client. Because they are copies, both test modules re-read Opentrons' own definitions and fail on any drift. Those tests skip when the package is absent, so they cost a contributor nothing and catch a stale transcription for anyone who has it. An unrecorded pipette model now raises and names flow_rate as the fix, instead of running at a neighbouring version's rate. Versions of one model differ by far too much to guess with: a p1000 on a 50 uL tip is 6 uL/s at v3.3 and 478 at v3.4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@vcjdeboer We built on this branch and opened a PR against it: vcjdeboer#1 (raising it here since people watch this PR rather than incoming PRs on a fork). Adds the gripper, containers/reservoirs, motion, fine pipetting, custom labware upload, and per-pipette flow rates, and runs it on real hardware for the first time: a Flex with a p50 single channel, 35 checks so far. Nothing removed, 25 of your public methods untouched. Two things worth flagging even if you don't want the PR:
Happy to split any of it up or drop parts if you'd rather keep this PR lean for the rebase onto main. |
Loading by name was gated on a client-side list of Opentrons' shipped load names. That list cannot be right. A robot resolves a name against the definitions its own software shipped with AND the custom labware a lab has added to it (api/src/opentrons/protocols/labware.py:85-105, and the opentrons -> custom_beta namespace fallback at :262-269), so the answer depends on the robot in front of you, not on a package version on the client. It also made anyone who adds labware -- a PyLabRobot user, a downstream driver -- edit a list in here to use their own. Declaring `ot_load_name` is now the whole rule: a resource that declares one is loaded by that name and the robot accepts or rejects it, and a resource that declares none gets a definition built from its geometry and uploaded. A name the robot cannot resolve fails the loadLabware, which surfaces where a caller expects it, as the labware goes onto the deck. Sniffing `resource.model` for something that looked like a load name is gone with it. Every Opentrons resource PyLabRobot ships already sets `ot_load_name` itself, so nothing needed it, and a PLR model that merely resembles a load name would have loaded the wrong labware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A map of load name -> revision sat next to the load-name list, pinning 13 plates to revision 2 or 3 because revision 1 predates the Flex and states no gripper grip height, so the robot grips at the plate's mid-height instead. Same objection as the list it sat next to, and a worse failure mode: which revisions a robot holds depends on its software, and naming one it does not hold fails the load outright. The map could break loading on an older robot in order to grip better on a newer one. `ot_version` now carries it and defaults to 1, the revision every definition has and the only one safe to assume. `flex_plate()` takes a `version` argument so the revision sits next to the load name, where whoever describes the labware can state it, and `corning_96_wellplate_360ul_flat` passes 2 to keep its 12.2 mm grip height. Also drops a SHARED_DATA_VERSION constant in pipette_defaults that nothing read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pabilities Flex: gripper, containers, motion, fine pipetting, and a first hardware run
… a tip rack Collapse the 8-channel liquid-handling and pickup surface onto one method each, keyed by a per-call nozzle selection, and make deck travel unconditionally safe. Unified surface (the per-config methods become thin bridges, callers keep working): - aspirate/dispense(target, volume, *, use_channels): target TYPE selects the addressing -- a plate.column(c) list -> the column (ALL); a bare Well -> the mounted single nozzle; a Container/Trough -> the shared cavity (tracker staged with volume x active channels). use_channels is a fail-closed assertion that must match the mounted channels. - pick_up_tips(target, *, use_channels): a rack.column(c) list -> ALL; a single TipSpot -> SINGLE; a contiguous partial -> QUADRANT. Pickup is where the layout is chosen and configureNozzleLayout is emitted (the engine refuses to reconfigure while a tip is attached). The partial config matches Opentrons' own configure_nozzle_layout (front-anchored -> primaryNozzle = frontRightNozzle = H1, backLeftNozzle = rear-most active). Reach + travel safety: - Reach caps derived from the OT-3 primitives (envelope.py/checks.py), not hardcoded; rear padding grounded to the 8.8.1 shared-data the robot runs (paddingOffsets.rear = -169.42, rear cap 324.38). - Travel plane computed from the deck (tallest labware + arc margin) instead of a fixed 120, and never below a tip rack (99 + margin) -- so travel clears a rack even when the deck model shows none. - Every cross-slot pipetting move is prefixed with a safe high moveToWell at that floor; within-slot moves keep the engine's low arc. The trash-drop carries the same floor. - Single-nozzle aspirate/dispense now runs the same idle-nozzle clearance check as pickup, refusing rather than clipping labware in the trailing slot. Tests cover the unified aspirate/dispense/pickup wire payloads, partial QUADRANT, reach grounding, computed/floored traversal, the cross-slot arc guard, and the single-nozzle clearance. ruff + mypy clean. A hardware smoke-test notebook drives the new surface end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Merged, thanks @miikee. I went through it closely and ran the new surface on the robot first. A few notes, plus a follow-up commit I layered on top. Verified against the robot's own 8.8.1 engine and kept as-is:
Two things to reconcile:
What I added on top (hardware-tested on the 8-ch head, full column plus single H1 plus a front-4 partial): the unified surface, partial columns, and a reach/travel layer. Reach caps are grounded to 8.8.1, and travel always arcs above a tip rack (cross-slot moves and the trash-drop lift to a computed floor, so an idle rack the robot wasn't told about is still cleared). That trash-drop was travelling low. |
The two Flex notebooks (hello-world.ipynb, use_channels_smoke.ipynb) exercise FlexHead8 aspirate/dispense, single-nozzle pickup/drop, and partial-column (QUADRANT) pickup on real 8-channel hardware, but the op-scoped verified set listed only pick_up_tips, so those already-proven ops emitted a spurious one-time untested-hardware notice -- and the QUADRANT partial-pickup path emitted no notice at all, so it was neither claimed nor tracked. Add pick_up_single_tip, pick_up_partial, drop_tips, drop_single_tip, aspirate, and dispense to FlexHead8._HARDWARE_VERIFIED_OPS; add the missing _warn_untested_hardware call to the partial-pickup path so it participates in the op-scoped coverage; and update the docstring to cite the two notebook runs and the exact verified layouts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- format: ruff format (FlexHead8 verified-ops set; a long assertEqual in flex_tests) and isort normalisation in checks.py / envelope.py - typecheck: mark the ad-hoc ot_load_name/ot_version test writes with type: ignore[attr-defined], matching the resource factories that set them - tests: guard the httpx import in io/http_tests and opentrons/transport_tests with pytest.importorskip (TYPE_CHECKING import for annotations) so installs without the opentrons extra skip these modules instead of erroring at collection - docs: escape the RST link-target in FlexGripper.move_labware's docstring and add the use_channels_smoke notebook to the opentrons toctree - spell: allow the "unparseable" spelling used by the version parser Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opentrons Flex liquid handler (plain-class, mount-addressed heads)
Adds the Opentrons Flex as a plain-class device, following the post-capability architecture. It drives the on-robot robot-server HTTP API (Protocol Engine:
/runs,/commands,/instruments) directly.Architecture
OpentronsRobot(abc.ABC)— shared base owning the transport, the run/command lifecycle, and instrument discovery. The transport sits behind a smallOpentronsTransportProtocol with two implementations: anhttpxtransport for real hardware and an offline recording transport for dry runs (so the whole device is testable without a robot).OpentronsFlex(OpentronsRobot)— the device.setup()discovers the mounted pipette(s) and composes a head sub-object per gantry mount (flex.left/flex.right), orflex.head96for the 96-channel head.stop()drops any mounted tips to the trash, homes the gantry, then cancels the run and disconnects.FlexHead1/FlexHead8/FlexHead96— mount-addressed fixed heads. Each operation sends one robot-server command anchored at the reference well; the fixed head fans it out to its N nozzles (a column for Head8, the whole plate for Head96, a single well for Head1). Head8 also supports single/partial pickup viaconfigureNozzleLayout.Behaviour
TipSpot.tracker/Well.tracker, only for the channels actually actuated (None-skip), and only through a transactional stage → wire → verify → commit/rollback flow — so an infeasible operation raises before any hardware motion, and a failed wire command leaves no partial state. Gated by the globaldoes_tip_tracking()/does_volume_tracking()switches.tipDetectedsensor is the ground truth for tip presence: pickups are verified against it and rolled back on a missed pickup, andget_mounted_tips()(public per-channel telemetry) is reconciled against it.prepareToAspiratebefore the first aspirate after a pickup.ot_load_name); the PLR resources carry a nominal SBS grid for tracking/addressing only.FlexDeckmodels slots asResourceHolders; name-based tip-rack and plate factories are included.Hardware status
FlexHead8is verified on real Opentrons Flex hardware (robot-server API 8.8: setup, homing, and column tip pickup confirmed against the tip-presence sensor).FlexHead1andFlexHead96are implemented but not yet hardware-verified (they need 1-channel / 96-channel pipettes) and emit a one-time warning on first use.Docs & tests
docs/user_guide/opentrons/flex/hello-world.ipynb) and an API reference page, wired into the docs toctrees.Follow-ups (not in this PR)
FlexRobotGeometrynative reach checks (per-mount / nozzle-envelope), folding a grounded operating-envelope model.FlexDeckdeserialization guard (reject labware assigned directly to the deck).OpentronsRobotbase (pending verification that the two share enough at the wire level).