Skip to content

feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed heads) - #1184

Open
vcjdeboer wants to merge 37 commits into
PyLabRobot:mainfrom
vcjdeboer:feat/opentrons-plain-class
Open

feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed heads)#1184
vcjdeboer wants to merge 37 commits into
PyLabRobot:mainfrom
vcjdeboer:feat/opentrons-plain-class

Conversation

@vcjdeboer

@vcjdeboer vcjdeboer commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 small OpentronsTransport Protocol with two implementations: an httpx transport 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), 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 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 via configureNozzleLayout.

Behaviour

  • Resource-tree state. Tip and volume state commit to 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 global does_tip_tracking() / does_volume_tracking() switches.
  • Hardware tip-presence authority. The Flex's per-pipette tipDetected sensor is the ground truth for tip presence: pickups are verified against it and rolled back on a missed pickup, and get_mounted_tips() (public per-channel telemetry) is reconciled against it.
  • Positioning. Aspirate/dispense default to 1 mm above the well bottom (the Opentrons default) and auto-issue prepareToAspirate before the first aspirate after a pickup.
  • Name-based labware. The robot owns the authoritative labware geometry, resolved from the Opentrons load name (ot_load_name); the PLR resources carry a nominal SBS grid for tracking/addressing only. FlexDeck models slots as ResourceHolders; name-based tip-rack and plate factories are included.

Hardware status

FlexHead8 is verified on real Opentrons Flex hardware (robot-server API 8.8: setup, homing, and column tip pickup confirmed against the tip-presence sensor). FlexHead1 and FlexHead96 are implemented but not yet hardware-verified (they need 1-channel / 96-channel pipettes) and emit a one-time warning on first use.

Docs & tests

  • A Head8 hello-world notebook (docs/user_guide/opentrons/flex/hello-world.ipynb) and an API reference page, wired into the docs toctrees.
  • Offline unit tests for the transport, device composition, and all three heads (via the recording transport).

Follow-ups (not in this PR)

  • FlexRobotGeometry native reach checks (per-mount / nozzle-envelope), folding a grounded operating-envelope model.
  • FlexDeck deserialization guard (reject labware assigned directly to the deck).
  • OT-2 on the shared OpentronsRobot base (pending verification that the two share enough at the wire level).

@rickwierenga
rickwierenga force-pushed the v1b1 branch 2 times, most recently from 6af085c to 1ae9dc6 Compare August 1, 2026 20:32
@Silousr

Silousr commented Aug 4, 2026

Copy link
Copy Markdown

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:

  • FlexDeck behaves as a first-class Deck throughout. Resource traversal, name lookup, per-well tip and volume trackers, and our reference validation all worked over it with zero changes to our resource code. The slot-as-child-resource design is what made that true.
  • The list-shaped method signatures (pick_up_tips, drop_tips, aspirate, dispense) are close enough to the LiquidHandler shapes that our existing handlers called the driver without modification.
  • One command per HTTP POST maps well to honest cancellation semantics. After the earlier Flex discussion we ended up declaring every atomic operation non-cancellable and only bridge-sequenced compounds stoppable between steps; this driver's command granularity made that mapping exact.

Things I hit that may be worth knowing:

  • Installing the PR head as a package (pip install from the commit) cannot import pylabrobot.liquid_handling: the chain fails at pylabrobot.molecular_devices.imageXpress, which is missing from the installed package at that commit. Looks like a packaging artifact of the older v1b1 base rather than anything in the driver files; current v1b1 does not have the problem, so a rebase presumably clears it. Only matters to someone consuming the PR as an install rather than a checkout.
  • Separately, on current v1b1 (1ae9dc6), our shipped LiquidHandler-based test suite passes 110 of 113 through the legacy shims. The three failures, in case the data is useful: pylabrobot.liquid_handling.errors has no shim module (old-path import of ChannelizedError breaks), and the Cor_96_wellplate_360ul_Fb factory now produces model string cor_96_wellplate_360uL_Fb, which breaks anything keyed on the old model string. In our case that key was a safety annotation, so the mismatch failed silent rather than loud, which was a good lesson about string-keyed config on our side.
  • Multi-element calls: the driver builds each robot command from element [0] of its list parameters but commits tracker state for every zipped element, so a two-well aspirate updates both wells' volume trackers while the robot aspirates only the first. The PR already describes itself as single-channel-first, so this is expected territory; the part that bit us as a consumer is that the tracker side is silent about it. Our adapter refuses multi-element calls until batching lands, which was an easy guard once we saw it.
  • Channel state: which physical channel holds which tip lives in _channel_tips, which is private. LiquidHandler.head was public, and our telemetry read it. In the plain-class model there was nothing public for an adapter to read for that, so we read the private attribute and documented that we did. Just noting where the public surface ended for us; the resource-tree trackers covered everything else.

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.

@rickwierenga

Copy link
Copy Markdown
Member

thank you @vcjdeboer !

could you please rebase this onto main?

@vcjdeboer
vcjdeboer force-pushed the feat/opentrons-plain-class branch from 6ee378e to 06971f2 Compare August 5, 2026 07:58
@vcjdeboer
vcjdeboer changed the base branch from v1b1 to main August 5, 2026 07:58
@vcjdeboer
vcjdeboer force-pushed the feat/opentrons-plain-class branch from 06971f2 to 5166ba4 Compare August 5, 2026 13:37
@vcjdeboer vcjdeboer changed the title feat(opentrons): Opentrons Flex liquid handler driver feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed heads) Aug 5, 2026
…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>
@vcjdeboer
vcjdeboer force-pushed the feat/opentrons-plain-class branch from 5166ba4 to 5f3869c Compare August 5, 2026 13:48
@vcjdeboer

Copy link
Copy Markdown
Contributor Author

@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:

  • Multi-element tracker divergence: the fixed-head model has no arbitrary use_channels path. Each op sends one command anchored at the reference well and the hardware fans it to the head's nozzles; trackers
  • commit only for actuated channels (None-skip), via stage → wire → verify → commit/rollback. Arbitrary multi-well isn't offered rather than silently mishandled.
  • Channel→tip state: get_mounted_tips() is public now, and has_tip_on_hardware() reads the Flex tipDetected sensor, which is the authority. Pickups verify against it and roll back on a miss.

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.

miikee and others added 19 commits August 12, 2026 09:42
- 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>
miikee and others added 11 commits August 13, 2026 17:16
…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>
@miikee

miikee commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

@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:

  • The driver was sending its own prepareToAspirate before a well-addressed aspirate. The robot already primes those itself, at the well top in open air, then descends. Sending our own first set the ready flag and skipped that, so the prime happened wherever the tip already was: submerged, that draws roughly 3.9 uL on a p50 or 79.5 uL on a p1000 of the wrong well into the tip. It passed unit tests because the chatterbox accepted whatever the driver sent; it now models the engine's ready-to-aspirate rule.
  • Dropping opentrons-shared-data matters more than it looks. It pins numpy~=1.26.4, which fights whatever numpy the rest of an environment wants. It was only read for two static tables, so those now live in the tree with drift tests against Opentrons' own definitions.

Happy to split any of it up or drop parts if you'd rather keep this PR lean for the rebase onto main.

miikee and others added 4 commits August 17, 2026 23:52
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>
@vcjdeboer

Copy link
Copy Markdown
Contributor Author

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:

  • Priming fix is right. A well-addressed aspirate self-primes at the well top (commands/aspirate.py) and a pickup primes the plunger (execution/tip_handler.py:342), so my old client-side prepareToAspirate was priming submerged and drawing the well in. Good catch.
  • io.HTTP was empty for the OT2, copied that pattern; filling it with capture/replay is the right call.
  • Per-model flow rates confirmed (p1000 on a 50 µL tip really is 6 µL/s at v3.3 vs 478 at v3.4), and the drift test is nice.
  • getTipPresence: agreed it's simulator-only (legacy_simulator/legacy_instrument_core.py:411), not a hardware bug.

Two things to reconcile:

  1. Aspirate surface. My follow-up unifies (plate, column) + aspirate_single + aspirate_container into one aspirate(target, volume, *, use_channels) per head (same for pick_up_tips, which is where the layout is emitted since the engine won't reconfigure with tips on). Your container tracker-math and flow rates stay underneath, the old calls stay as bridges, and it scales to partial columns (QUADRANT). Happy to iterate.
  2. numpy / shared-data. I think this got tangled between our parts. opentrons-shared-data doesn't pin numpy (8.3.0 and the robot's 8.8.1 both declare only jsonschema/pydantic/typing-extensions); the numpy<2 constraint is the opentrons package's, which shared-data doesn't require, and our extra is opentrons-http-api-client==0.2.1, which has no deps at all. My base never imported shared-data at runtime either (only mypy.ini). The vendoring plus drift test is still good; I just want the rationale to match.

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.

vcjdeboer and others added 2 commits August 20, 2026 18:25
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>
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.

4 participants